Compare commits

...

3 Commits

Author SHA1 Message Date
alexei.dolgolyov c1eeefcf06 Merge feat/roadmap-2026-06-19: roadmap batches (solar/image-quality/per-pixel + integrations)
Round one (6745e25): SolarRule, arm64 Docker, LoL poller, color-harmony
gradients, reactive palette, linear-light, dithering, Nanoleaf extControl.
Round two (39b0554): LIFX multizone/Tile, Hue segment mapping, outbound
webhook action, HA MQTT auto-discovery.
2026-06-23 00:55:49 +03:00
alexei.dolgolyov 39b0554444 feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations
A) Per-pixel smart-lights
- LIFX multizone (SetExtendedColorZones msg 510, <=82 zones) + Tile
  (SetTileState64 715), auto-detected on connect with single-colour
  fallback; lifx_per_zone threaded like nanoleaf_per_panel
- Hue gradient-lightstrip mapping: Entertainment v2 frame now keyed by
  channel id (was 1 light=1 LED), channels discovered on connect;
  hue_gradient_mode toggle (default on)

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

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

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

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

Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing
test (automation manual_trigger handler coverage) is a separate in-progress
item owned elsewhere, intentionally left as-is.
2026-06-22 23:21:24 +03:00
112 changed files with 6345 additions and 573 deletions
+52
View File
@@ -354,6 +354,58 @@ jobs:
docker push "$REGISTRY:latest" docker push "$REGISTRY:latest"
fi fi
# Best-effort arm64 (Raspberry Pi / arm64 HAOS hosts). Runs AFTER the
# amd64 push so the amd64 image always ships even if this fails.
# Deliberately avoids `docker buildx` (its docker-container driver needs
# nested networking the TrueNAS runners lack — see contexts/ci-cd.md):
# instead it cross-builds a single arm64 image via QEMU binfmt and folds
# amd64 + arm64 into multi-arch manifest lists under the existing tags.
# `continue-on-error` keeps a runner that can't emulate arm64 from
# failing the release; the plain amd64 tags pushed above remain valid.
- name: Build + publish arm64 (multi-arch manifest, best-effort)
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
continue-on-error: true
run: |
set -e
export DOCKER_CLI_EXPERIMENTAL=enabled
TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}"
VERSION="${{ steps.meta.outputs.version }}"
# Register arm64 emulation. If the runner forbids privileged
# containers this fails and the whole step is skipped.
docker run --privileged --rm tonistiigi/binfmt --install arm64
# Cross-build the arm64 image (QEMU-emulated — slow but uses arm64
# manylinux wheels, so no source compilation). Stays in the local
# daemon alongside the amd64 image from the previous build step.
DOCKER_BUILDKIT=1 docker build \
--platform linux/arm64 \
--build-arg APP_VERSION="$VERSION" \
--label "org.opencontainers.image.version=$VERSION" \
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
-t "$REGISTRY:$VERSION-arm64" \
./server
# Fold amd64 + arm64 into a multi-arch manifest list under each
# user-facing tag. The arch-suffixed tags remain pullable directly.
publish_manifest() {
local t="$1"
docker tag "$REGISTRY:$t" "$REGISTRY:$t-amd64"
docker push "$REGISTRY:$t-amd64"
docker tag "$REGISTRY:$VERSION-arm64" "$REGISTRY:$t-arm64"
docker push "$REGISTRY:$t-arm64"
docker manifest create --amend "$REGISTRY:$t" \
"$REGISTRY:$t-amd64" "$REGISTRY:$t-arm64"
docker manifest push "$REGISTRY:$t"
}
publish_manifest "$TAG"
publish_manifest "$VERSION"
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
publish_manifest "latest"
fi
# ── Publish the release (flip draft=false) ───────────────── # ── Publish the release (flip draft=false) ─────────────────
# Runs only after every build job succeeded so users never see a # Runs only after every build job succeeded so users never see a
# release that's missing artifacts or sha256 sidecars (the in-app # release that's missing artifacts or sha256 sidecars (the in-app
+33 -2
View File
@@ -2,9 +2,40 @@
## Code Search ## Code Search
**If `ast-index` is available, use it as the PRIMARY code search tool.** It is significantly faster than grep and returns structured, accurate results. Fall back to grep/Glob only when ast-index is not installed, returns empty results, or when searching regex patterns/string literals/comments. **Priority order: `vex` (PRIMARY) → `ast-index` (fallback) → Grep/Glob (last resort).** This repo has a fully-featured `.vex.toml` index. Use vex first for any symbol/definition/usage/call-graph lookup. Fall back to ast-index only when vex legitimately can't help, and to Grep/Glob only for regex patterns, string literals, comments, config files, or unparsed languages.
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt. **IMPORTANT — use ALL vex indexing features.** The index is built with every capability enabled, and queries must take advantage of them. Keep them ON and exploit them:
| Capability | Status | Powers |
| ---------- | ------ | ------ |
| Semantic embeddings (`jina-code`, 768-dim) | ON | `vex search` (semantic channel), `similar`, `find_similar`, `duplicates` |
| Call graph | ON | `vex callers`, `callees`, `paths`, `reachable`, `bundle --mode pr-impact` |
| BM25 | ON | hybrid RRF text channel in `vex search` |
| Pattern index | ON | `vex pattern` AST-shape matching |
| C++ includes | ON | include-graph resolution |
| Body tokens (incremental HNSW) | ON | fast incremental reindex |
| History | ON | `vex history`, `vex diff <rev>` blame/evolution queries |
**In-session, use the `mcp__vex__*` MCP tools** (`search`, `show`, `usages`, `callers`, `callees`, `bundle`, `outline`, `implementations`, `similar`, `grep`, `status`, etc.) — MCP output is far cheaper in tokens than `Bash("vex …")`. Drop to Bash `vex` only for CLI-only features (`pattern`, `diff`, `paths`, `reachable`, `bundle`, `history`, `--strict`/`--why` flags), for subagent prompts, or for shell composition.
```bash
vex search "query" --semantic # Hybrid semantic + BM25 search
vex show <Symbol> # Definition body (prefer over Read)
vex usages <Symbol> --strict # Reference sites (AST-precise on T1 langs)
vex callers <Function> # Call sites (function-scoped)
vex callees <Function> # Outgoing calls
vex paths --from <A> --to <B> # Multi-hop call-graph path
vex bundle --mode pr-impact --base master # Changed symbols + callers + reachable tests
vex pattern '$X async fn returning Response' # AST-shape (metavariables)
vex diff master # Symbol-level branch diff
vex history <Symbol> # Commit evolution of a symbol
```
**Maintenance:** the index has `auto_update = true`, so it refreshes on stale queries. After a `vex self-update`, rerun `vex index --history --semantic --embedder jina-code --device cuda` so newly-added extractors populate and all features stay enabled. Verify with `vex status` — every capability line should read `yes`.
**IMPORTANT for subagents:** Subagents don't inherit MCP. When spawning Agent subagents (Plan, Explore, general-purpose, etc.), instruct them to use `vex` via Bash for code search (e.g. include "Use `vex search`, `vex show`, `vex usages`, `vex callers` via Bash for code search; ast-index is the fallback"). Don't tell them to default to grep/Glob.
**Fallback — `ast-index`** (use only when vex is unavailable):
```bash ```bash
ast-index search "Query" # Universal search ast-index search "Query" # Universal search
@@ -32,15 +32,15 @@ class ApiKeyManager(context: Context) {
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken // 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 // or absent keystore, or a key got corrupted), creation throws — fall back
// to plain SharedPreferences so a keystore failure NEVER bricks the local // to plain SharedPreferences so a keystore failure NEVER bricks the local
// API key (which would 401 every LAN client). [encrypted] records which // API key (which would 401 every LAN client).
// path we took so we don't repeatedly attempt migration.
private val encrypted: Boolean
private val prefs: SharedPreferences private val prefs: SharedPreferences
init { init {
val (store, isEncrypted) = buildPrefs(appContext) val (store, isEncrypted) = buildPrefs(appContext)
prefs = store 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() if (isEncrypted) migrateLegacyKeyIfPresent()
} }
@@ -113,26 +113,48 @@ class ApiKeyManager(context: Context) {
*/ */
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> { private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
return try { return try {
val masterKey = MasterKey.Builder(context) createEncrypted(context) to true
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val store = EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
store to true
} catch (e: Exception) { } catch (e: Exception) {
// Keystore unavailable/corrupt — degrade to plain prefs rather // The keystore can become invalidated (OS upgrade, device restore,
// than crashing. Worst case the key is stored unencrypted on a // OEM keystore bug), after which create() throws on EVERY launch and
// single-user TV box, which is the pre-existing behaviour. // the corrupt encrypted file is never cleaned up — degrading to plain
Log.w(TAG, "EncryptedSharedPreferences unavailable, using plain prefs: ${e.message}") // prefs forever and (because the live key was only in the encrypted
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false // 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 * 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 * (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 * Recover a still-present key from the legacy plain store — either the live
* key (failed/never-run migration) or the `.migrated` backup. Returns null * 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 * only when no valid key survives.
* survives. Guarantees [getOrCreateKey] never rotates an existing key as long *
* as the legacy file 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? { private fun recoverLegacyKey(): String? {
if (!encrypted) return null
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val candidate = legacy.getString(KEY_API_KEY, null) val candidate = legacy.getString(KEY_API_KEY, null)
?: legacy.getString(KEY_API_KEY_MIGRATED, null) ?: legacy.getString(KEY_API_KEY_MIGRATED, null)
@@ -42,6 +42,12 @@ class LedGrabNotificationListener : NotificationListenerService() {
// onListenerConnected can't race two executors into existence. // onListenerConnected can't race two executors into existence.
private val executorLock = Any() private val executorLock = Any()
// Tracks whether the listener is currently connected. ensureExecutor() only
// CREATES a new executor while connected — otherwise a notification racing
// onListenerDisconnected (which nulls pushExecutor) would spin up a fresh
// executor that nothing reaps until the next disconnect cycle (a thread leak).
@Volatile private var connected: Boolean = false
// packageName -> resolved human-readable label. Matches the app_name the // packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working. // Windows/Linux backends pass, so per-app colors/filters keep working.
// Naturally bounded by the number of notification-posting apps (tens) and // 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 // executor that onListenerDisconnected is shutting down throws
// RejectedExecutionException — guard with runCatching so a notification // RejectedExecutionException — guard with runCatching so a notification
// racing teardown can never crash this system-bound service. // 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 { runCatching {
executor.execute { executor.execute {
try { try {
@@ -116,13 +125,16 @@ class LedGrabNotificationListener : NotificationListenerService() {
} }
/** /**
* Return the push executor, creating it under [executorLock] if absent. * Return the push executor, creating it under [executorLock] if absent AND
* Safe against a concurrent onListenerConnected/onNotificationPosted race * the listener is connected. Returns null when disconnected so a notification
* (single executor) and against a missing onListenerConnected callback. * 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 } pushExecutor?.let { return it }
synchronized(executorLock) { synchronized(executorLock) {
if (!connected) return null
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it } return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
} }
} }
@@ -134,15 +146,16 @@ class LedGrabNotificationListener : NotificationListenerService() {
// executor here rather than in onCreate/onDestroy. onNotificationPosted // executor here rather than in onCreate/onDestroy. onNotificationPosted
// also lazily creates it (via ensureExecutor) in case this callback is // also lazily creates it (via ensureExecutor) in case this callback is
// late or skipped on some ROMs. // late or skipped on some ROMs.
connected = true
ensureExecutor() ensureExecutor()
} }
override fun onListenerDisconnected() { override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected") Log.i(TAG, "Notification listener disconnected")
// Tear the executor down on disconnect; a fresh one is created on the // Mark disconnected BEFORE nulling the executor so a racing ensureExecutor
// next onListenerConnected. Null out first so any in-flight // sees !connected and skips creating a replacement. Tear the executor
// onNotificationPosted snapshots see null (skips submit) rather than // down; a fresh one is created on the next onListenerConnected.
// racing a shutdown executor. connected = false
pushExecutor?.let { exec -> pushExecutor?.let { exec ->
pushExecutor = null pushExecutor = null
exec.shutdown() exec.shutdown()
@@ -152,6 +165,7 @@ class LedGrabNotificationListener : NotificationListenerService() {
override fun onDestroy() { override fun onDestroy() {
// Defensive: onListenerDisconnected normally clears this first, but // Defensive: onListenerDisconnected normally clears this first, but
// shut down here too in case onDestroy fires without a prior disconnect. // shut down here too in case onDestroy fires without a prior disconnect.
connected = false
pushExecutor?.shutdown() pushExecutor?.shutdown()
pushExecutor = null pushExecutor = null
super.onDestroy() super.onDestroy()
+7 -1
View File
@@ -31,9 +31,15 @@ Creates the Gitea release with a description table listing all artifacts. **The
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`** - Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
### 4. `build-docker` ### 4. `build-docker`
- Plain `docker build` + `docker push` (no Buildx — TrueNAS runners lack nested networking) - Plain `docker build` + `docker push` for **amd64** (no Buildx — TrueNAS runners lack nested networking)
- Registry: `{gitea_host}/{repo}:{tag}` - Registry: `{gitea_host}/{repo}:{tag}`
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc) - Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
- **arm64 is best-effort** (Raspberry Pi / arm64 HAOS): a `continue-on-error` step
cross-builds arm64 via **QEMU binfmt** (`tonistiigi/binfmt`) + `docker manifest`
(NOT buildx — sidesteps the docker-container-driver networking limit) and folds
amd64 + arm64 into multi-arch manifest lists under the same tags, plus
`:{tag}-amd64` / `:{tag}-arm64` arch-suffixed tags. If the runner can't run
privileged binfmt the step is skipped and the amd64 tags above remain valid.
## Build Scripts ## Build Scripts
+81 -35
View File
@@ -3,7 +3,9 @@
import asyncio import asyncio
import json import json
import secrets import secrets
import threading
import time import time
from collections import OrderedDict
from typing import Annotated from typing import Annotated
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -31,15 +33,26 @@ logger = get_logger(__name__)
# suppressed — only the *audit recording* is de-duplicated. # suppressed — only the *audit recording* is de-duplicated.
# #
# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP # 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 # entries. When the cap is exceeded the oldest-inserted IP is evicted in O(1)
# evicted so the dict stays bounded regardless of the number of distinct source # so the dict stays bounded regardless of the number of distinct source IPs an
# IPs an attacker can forge. # 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_RECORD_WINDOW: float = 10.0 # seconds — one record per IP per window
_AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously _AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously
# ip -> monotonic timestamp of last *recorded* auth.rejected entry # ip -> monotonic timestamp of last *recorded* auth.rejected entry.
_auth_record_last: dict[str, float] = {} # 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: 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 Suppresses duplicates within _AUTH_RECORD_WINDOW seconds. Evicts the
oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent
unbounded memory growth under IP-spray attacks. 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() now = time.monotonic()
last = _auth_record_last.get(client_ip) with _auth_record_lock:
if last is not None and (now - last) < _AUTH_RECORD_WINDOW: last = _auth_record_last.get(client_ip)
return False # suppress: within the de-dup window 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. # Enforce hard cap before inserting: evict the oldest entry in O(1).
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP: if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
oldest_ip = min(_auth_record_last, key=lambda ip: _auth_record_last[ip]) _auth_record_last.popitem(last=False)
del _auth_record_last[oldest_ip]
_auth_record_last[client_ip] = now # Refresh recency: move/insert this IP to the most-recent end so the
return True # 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: 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 THROTTLE: at most one ``auth.rejected`` record is written per client IP
per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification
DoS. The 401 response is always returned regardless. 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"): try:
return # throttled — drop duplicate recording for this IP/window 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() rec = get_module_recorder()
if rec is None: if rec is None:
return return
rec.record( rec.record(
category=ActivityCategory.AUTH, category=ActivityCategory.AUTH,
action="auth.rejected", action="auth.rejected",
severity=ActivitySeverity.WARNING, severity=ActivitySeverity.WARNING,
actor="anonymous", actor="anonymous",
message=f"Authentication failed: {reason}", message=f"Authentication failed: {reason}",
metadata={"reason": reason, "client": client_host or "unknown"}, 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: 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) return _classify_is_loopback(host)
def verify_api_key( async def verify_api_key(
request: Request, request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)], credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str: ) -> str:
@@ -156,6 +182,13 @@ def verify_api_key(
LAN access requires an API key). LAN access requires an API key).
- When API keys ARE configured, valid Bearer credentials are required. - When API keys ARE configured, valid Bearer credentials are required.
This is an ``async`` dependency on purpose: token comparison is CPU-trivial,
and an async dependency runs in the SAME task/context as the route handler,
so ``current_actor.set(...)`` below is visible to ``ActivityRecorder`` when
the handler later records an entity event. A sync dependency would run in a
throwaway threadpool context and the actor mutation would be discarded,
attributing every audited action to "system".
Args: Args:
request: incoming request (used to read client host) request: incoming request (used to read client host)
credentials: HTTP authorization credentials credentials: HTTP authorization credentials
@@ -202,10 +235,16 @@ def verify_api_key(
# Extract token — NEVER log or record the token value itself. # Extract token — NEVER log or record the token value itself.
token = credentials.credentials token = credentials.credentials
# Find matching key and return its label using constant-time comparison # Find matching key and return its label using constant-time comparison.
# Compare UTF-8 byte encodings: secrets.compare_digest raises TypeError on
# non-ASCII str (an attacker can put 0x80-0xFF in the Authorization header,
# which Starlette latin-1-decodes to a non-ASCII str). Byte comparison is
# well-defined for any input and preserves constant-time behavior, so a
# bad/non-ASCII token cleanly falls through to the 401 below instead of 500.
token_b = (token or "").encode("utf-8")
authenticated_as = None authenticated_as = None
for label, api_key in config.auth.api_keys.items(): for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key): if secrets.compare_digest(token_b, api_key.encode("utf-8")):
authenticated_as = label authenticated_as = label
break break
@@ -235,7 +274,7 @@ def verify_api_key(
AuthRequired = Annotated[str, Depends(verify_api_key)] AuthRequired = Annotated[str, Depends(verify_api_key)]
def verify_docs_access( async def verify_docs_access(
request: Request, request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)], credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str: ) -> str:
@@ -253,7 +292,7 @@ def verify_docs_access(
if get_config().auth.expose_docs: if get_config().auth.expose_docs:
request.state.auth_label = "anonymous-docs" request.state.auth_label = "anonymous-docs"
return "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 # 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: def _match_api_key(token: str) -> str | None:
"""Return the label matching *token* using constant-time comparison, or None.""" """Return the label matching *token* using constant-time comparison, or None.
Compares UTF-8 byte encodings so a non-ASCII token (a JSON string in the WS
auth message trivially carries non-ASCII) cannot raise TypeError out of
``secrets.compare_digest`` — it simply fails to match and yields a clean
``auth_error`` instead of crashing the handler.
"""
config = get_config() config = get_config()
if not token: if not token:
return None return None
token_b = token.encode("utf-8")
for label, api_key in config.auth.api_keys.items(): for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key): if secrets.compare_digest(token_b, api_key.encode("utf-8")):
return label return label
return None return None
+40 -24
View File
@@ -37,6 +37,7 @@ from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.storage.game_integration_store import GameIntegrationStore from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
from ledgrab.storage.mqtt_source_store import MQTTSourceStore from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.core.mqtt.mqtt_manager import MQTTManager from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
@@ -173,6 +174,15 @@ def get_game_event_bus() -> GameEventBus:
return _get("game_event_bus", "Game event bus") return _get("game_event_bus", "Game event bus")
def get_lol_poll_manager() -> LoLPollManager | None:
"""LoL poll manager, or None if not wired (e.g. minimal test harnesses).
Polling is a best-effort background feature, so callers guard on None
rather than 500-ing a CRUD request when the manager is absent.
"""
return _deps.get("lol_poll_manager")
def get_mqtt_store() -> MQTTSourceStore: def get_mqtt_store() -> MQTTSourceStore:
return _get("mqtt_store", "MQTT source store") return _get("mqtt_store", "MQTT source store")
@@ -216,36 +226,40 @@ def get_activity_log_retention_engine() -> ActivityLogRetentionEngine:
# ── Event helper ──────────────────────────────────────────────────────── # ── 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: 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. """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 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). 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) entry = _STORE_LOOKUP.get(entity_type)
if entry is None: if entry is None:
return None return None
@@ -356,6 +370,7 @@ def init_dependencies(
ha_manager: HomeAssistantManager | None = None, ha_manager: HomeAssistantManager | None = None,
game_integration_store: GameIntegrationStore | None = None, game_integration_store: GameIntegrationStore | None = None,
game_event_bus: GameEventBus | None = None, game_event_bus: GameEventBus | None = None,
lol_poll_manager: LoLPollManager | None = None,
mqtt_store: MQTTSourceStore | None = None, mqtt_store: MQTTSourceStore | None = None,
mqtt_manager: MQTTManager | None = None, mqtt_manager: MQTTManager | None = None,
http_endpoint_store: HTTPEndpointStore | None = None, http_endpoint_store: HTTPEndpointStore | None = None,
@@ -397,6 +412,7 @@ def init_dependencies(
"ha_manager": ha_manager, "ha_manager": ha_manager,
"game_integration_store": game_integration_store, "game_integration_store": game_integration_store,
"game_event_bus": game_event_bus, "game_event_bus": game_event_bus,
"lol_poll_manager": lol_poll_manager,
"mqtt_store": mqtt_store, "mqtt_store": mqtt_store,
"mqtt_manager": mqtt_manager, "mqtt_manager": mqtt_manager,
"http_endpoint_store": http_endpoint_store, "http_endpoint_store": http_endpoint_store,
+58 -26
View File
@@ -57,6 +57,7 @@ from ledgrab.api.schemas.activity_log import (
) )
from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine 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 import ActivityCategory, ActivityLogFilters, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository 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 _MAX_LIMIT = 200
_DEFAULT_LIMIT = 50 _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 export columns (matches entry_to_dict key order)
_CSV_COLUMNS = [ _CSV_COLUMNS = [
"id", "id",
@@ -85,6 +92,11 @@ _CSV_COLUMNS = [
# Leading TAB and CR are also recognised triggers by Excel / Google Sheets. # Leading TAB and CR are also recognised triggers by Excel / Google Sheets.
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r") _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: def _csv_safe(value: str) -> str:
"""Prefix formula-injection triggers with a literal single-quote. """Prefix formula-injection triggers with a literal single-quote.
@@ -97,6 +109,23 @@ def _csv_safe(value: str) -> str:
return value return value
def _redact_for_anon(entry_dict: dict, auth_label: str) -> dict:
"""Redact the source-IP metadata for anonymous (loopback) callers.
The streaming export is gated by ``require_authenticated`` precisely because
the log can contain client IPs (e.g. ``auth.rejected`` / ``auth.ws_connected``
store ``metadata.client``). The list endpoint allows loopback-anonymous
callers, so to keep the posture consistent we mask that one field for the
``"anonymous"`` label rather than handing it back what export withholds.
"""
if auth_label != "anonymous":
return entry_dict
meta = entry_dict.get("metadata")
if isinstance(meta, dict) and "client" in meta:
return {**entry_dict, "metadata": {**meta, "client": "[redacted]"}}
return entry_dict
def _build_filters( def _build_filters(
categories: list[str] | None, categories: list[str] | None,
severities: list[str] | None, severities: list[str] | None,
@@ -127,7 +156,7 @@ def _build_filters(
@router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries") @router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries")
def list_activity_log( def list_activity_log(
auth: AuthRequired, # noqa: ARG001 auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo), repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Filters ──────────────────────────────────────────────────────────── # ── Filters ────────────────────────────────────────────────────────────
categories: Annotated[ categories: Annotated[
@@ -145,15 +174,15 @@ def list_activity_log(
] = None, ] = None,
actor: Annotated[ actor: Annotated[
str | None, str | None,
Query(description="Filter by actor label (exact match)"), Query(max_length=_MAX_ID_FILTER, description="Filter by actor label (exact match)"),
] = None, ] = None,
entity_type: Annotated[ entity_type: Annotated[
str | None, str | None,
Query(description="Filter by entity type (exact match)"), Query(max_length=_MAX_ID_FILTER, description="Filter by entity type (exact match)"),
] = None, ] = None,
entity_id: Annotated[ entity_id: Annotated[
str | None, str | None,
Query(description="Filter by entity id (exact match)"), Query(max_length=_MAX_ID_FILTER, description="Filter by entity id (exact match)"),
] = None, ] = None,
since: Annotated[ since: Annotated[
datetime | None, datetime | None,
@@ -165,7 +194,10 @@ def list_activity_log(
] = None, ] = None,
q: Annotated[ q: Annotated[
str | None, 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, ] = None,
# ── Pagination ───────────────────────────────────────────────────────── # ── Pagination ─────────────────────────────────────────────────────────
before_seq: Annotated[ before_seq: Annotated[
@@ -204,27 +236,21 @@ def list_activity_log(
# by slicing [1:], which is the actual page content for the client. # 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. # When we got <= limit rows, this is the last page and all rows are included.
effective_limit = min(limit, _MAX_LIMIT) effective_limit = min(limit, _MAX_LIMIT)
entries_plus = repo.query(filters, before_seq=before_seq, limit=effective_limit + 1) # query_with_seq returns (seq, entry) ascending (oldest-first within page),
has_more = len(entries_plus) > effective_limit # so the seq is already in hand — no extra get_seq_for_id round-trip.
if has_more: rows_plus = repo.query_with_seq(filters, before_seq=before_seq, limit=effective_limit + 1)
# Drop the oldest probe row; keep the newest `limit` entries. has_more = len(rows_plus) > effective_limit
entries = entries_plus[1:] # When over-fetched, drop the oldest probe row (index 0) and keep the newest.
else: rows = rows_plus[1:] if has_more else rows_plus
entries = entries_plus
total = repo.count(filters) total = repo.count(filters)
# Compute next_before_seq: the seq of the oldest entry on this page. # next_before_seq: the seq of the oldest entry on this page (rows[0]).
# query() returns entries ascending (entries[0] is oldest); its seq is the # The next request passes before_seq=X to get entries with seq < X.
# cursor for the next page. The next request passes before_seq=X to get next_before_seq: int | None = rows[0][0] if (has_more and rows) else None
# entries with seq < X, i.e. entries older than the oldest entry on this page.
# get_seq_for_id() does a cheap indexed point-lookup.
next_before_seq: int | None = None
if has_more and entries:
next_before_seq = repo.get_seq_for_id(entries[0].id)
return ActivityLogPageResponse( 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, next_before_seq=next_before_seq,
has_more=has_more, has_more=has_more,
total=total, total=total,
@@ -259,9 +285,15 @@ def _export_csv_generator(
row = [] row = []
for col in _CSV_COLUMNS: for col in _CSV_COLUMNS:
if col == "metadata": 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 {}) cell = json.dumps(d.get(col) or {})
else: 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)) row.append(_csv_safe(cell))
buf = io.StringIO() buf = io.StringIO()
writer = csv.writer(buf) writer = csv.writer(buf)
@@ -310,12 +342,12 @@ def export_activity_log(
# ── Same filters as list ─────────────────────────────────────────────── # ── Same filters as list ───────────────────────────────────────────────
categories: Annotated[list[str] | None, Query()] = None, categories: Annotated[list[str] | None, Query()] = None,
severities: Annotated[list[str] | None, Query()] = None, severities: Annotated[list[str] | None, Query()] = None,
actor: Annotated[str | None, Query()] = None, actor: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
entity_type: Annotated[str | None, Query()] = None, entity_type: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
entity_id: Annotated[str | None, Query()] = None, entity_id: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
since: Annotated[datetime | None, Query()] = None, since: Annotated[datetime | None, Query()] = None,
until: 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: ) -> StreamingResponse:
"""Stream all matching entries as CSV or JSON. """Stream all matching entries as CSV or JSON.
@@ -12,28 +12,35 @@ from ledgrab.api.dependencies import (
get_scene_preset_store, get_scene_preset_store,
) )
from ledgrab.api.schemas.automations import ( from ledgrab.api.schemas.automations import (
ActionSchema,
AutomationCreate, AutomationCreate,
AutomationListResponse, AutomationListResponse,
AutomationResponse, AutomationResponse,
AutomationTriggerResponse,
AutomationUpdate, AutomationUpdate,
RuleSchema, RuleSchema,
) )
from ledgrab.core.automations.automation_engine import AutomationEngine from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.storage.automation import ( from ledgrab.storage.automation import (
Action,
ApplicationRule, ApplicationRule,
DisplayStateRule, DisplayStateRule,
HomeAssistantRule, HomeAssistantRule,
HTTPPollRule, HTTPPollRule,
ManualTriggerRule,
MQTTRule, MQTTRule,
Rule, Rule,
SolarRule,
StartupRule, StartupRule,
SystemIdleRule, SystemIdleRule,
TimeOfDayRule, TimeOfDayRule,
WebhookAction,
WebhookRule, WebhookRule,
) )
from ledgrab.storage.automation_store import AutomationStore from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.safe_source import validate_polling_url
from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -55,6 +62,20 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
days_of_week=s.days_of_week or [], days_of_week=s.days_of_week or [],
timezone=s.timezone or "", timezone=s.timezone or "",
), ),
# SolarRule.from_dict validates events, clamps offsets/coords, and
# filters weekdays — route the raw schema values through it.
"solar": lambda: SolarRule.from_dict(
{
"start_event": s.start_event,
"start_offset_minutes": s.start_offset_minutes,
"end_event": s.end_event,
"end_offset_minutes": s.end_offset_minutes,
"latitude": s.latitude,
"longitude": s.longitude,
"days_of_week": s.days_of_week or [],
"timezone": s.timezone or "",
}
),
"system_idle": lambda: SystemIdleRule( "system_idle": lambda: SystemIdleRule(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5, idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
when_idle=s.when_idle if s.when_idle is not None else True, when_idle=s.when_idle if s.when_idle is not None else True,
@@ -72,6 +93,7 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
token=s.token or secrets.token_hex(16), token=s.token or secrets.token_hex(16),
), ),
"startup": lambda: StartupRule(), "startup": lambda: StartupRule(),
"manual_trigger": lambda: ManualTriggerRule(),
"home_assistant": lambda: HomeAssistantRule( "home_assistant": lambda: HomeAssistantRule(
ha_source_id=s.ha_source_id or "", ha_source_id=s.ha_source_id or "",
entity_id=s.entity_id or "", entity_id=s.entity_id or "",
@@ -95,6 +117,38 @@ def _rule_to_schema(r: Rule) -> RuleSchema:
return RuleSchema(**d) return RuleSchema(**d)
def _action_from_schema(s: ActionSchema) -> Action:
"""Build a domain Action from its request schema, validating the webhook URL.
The SSRF gate runs here (save time) AND again at fire time, closing the
DNS-rebinding window. A bad/blocked URL rejects the whole save with 400.
"""
if s.action_type != "webhook":
raise ValueError(f"Unknown action type: {s.action_type}")
url = (s.webhook_url or "").strip()
if not url:
raise ValueError("webhook action requires a webhook_url")
method = (s.method or "POST").upper()
if method not in ("POST", "PUT", "GET"):
raise ValueError(f"Invalid webhook method: {method}. Must be POST, PUT or GET.")
fire_on = s.fire_on or "activate"
if fire_on not in ("activate", "deactivate", "both"):
raise ValueError(f"Invalid fire_on: {fire_on}. Must be activate, deactivate or both.")
# Raises HTTPException(400) on a blocked/loopback/metadata target.
validate_polling_url(url)
return WebhookAction(
webhook_url=url,
method=method,
body_template=s.body_template or "",
content_type=s.content_type or "application/json",
fire_on=fire_on,
)
def _action_to_schema(a: Action) -> ActionSchema:
return ActionSchema(**a.to_dict())
def _automation_to_response( def _automation_to_response(
automation, engine: AutomationEngine, request: Request = None automation, engine: AutomationEngine, request: Request = None
) -> AutomationResponse: ) -> AutomationResponse:
@@ -130,6 +184,7 @@ def _automation_to_response(
last_activated_at=state.get("last_activated_at"), last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"), last_deactivated_at=state.get("last_deactivated_at"),
tags=automation.tags, tags=automation.tags,
actions=[_action_to_schema(a) for a in getattr(automation, "actions", [])],
icon=getattr(automation, "icon", "") or "", icon=getattr(automation, "icon", "") or "",
icon_color=getattr(automation, "icon_color", "") or "", icon_color=getattr(automation, "icon_color", "") or "",
created_at=automation.created_at, created_at=automation.created_at,
@@ -186,6 +241,7 @@ async def create_automation(
try: try:
rules = [_rule_from_schema(r) for r in data.rules] rules = [_rule_from_schema(r) for r in data.rules]
actions = [_action_from_schema(a) for a in data.actions]
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -201,6 +257,7 @@ async def create_automation(
deactivation_mode=data.deactivation_mode, deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id, deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags, tags=data.tags,
actions=actions,
icon=data.icon, icon=data.icon,
icon_color=data.icon_color, icon_color=data.icon_color,
) )
@@ -283,6 +340,13 @@ async def update_automation(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
actions = None
if data.actions is not None:
try:
actions = [_action_from_schema(a) for a in data.actions]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
try: try:
# If disabling, deactivate first # If disabling, deactivate first
if data.enabled is False: if data.enabled is False:
@@ -297,6 +361,7 @@ async def update_automation(
rules=rules, rules=rules,
deactivation_mode=data.deactivation_mode, deactivation_mode=data.deactivation_mode,
tags=data.tags, tags=data.tags,
actions=actions,
icon=data.icon, icon=data.icon,
icon_color=data.icon_color, icon_color=data.icon_color,
) )
@@ -394,3 +459,33 @@ async def disable_automation(
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine, request) return _automation_to_response(automation, engine, request)
# ===== Manual trigger =====
@router.post(
"/api/v1/automations/{automation_id}/trigger",
response_model=AutomationTriggerResponse,
tags=["Automations"],
)
async def trigger_automation(
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Manually fire an automation.
Evaluates the automation's rules with its manual trigger satisfied — so it
"still checks all of the rules" under the automation's ``rule_logic`` — and,
if it should activate, applies its scene once. Independent of the ``enabled``
flag (that gates only the background evaluation loop).
"""
try:
automation = store.get_automation(automation_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
status, errors = await engine.fire_manual_trigger(automation)
return AutomationTriggerResponse(status=status, errors=errors)
+11
View File
@@ -96,13 +96,16 @@ def _device_to_response(device) -> DeviceResponse:
espnow_channel=device.espnow_channel, espnow_channel=device.espnow_channel,
hue_paired=bool(device.hue_username and device.hue_client_key), hue_paired=bool(device.hue_username and device.hue_client_key),
hue_entertainment_group_id=device.hue_entertainment_group_id, hue_entertainment_group_id=device.hue_entertainment_group_id,
hue_gradient_mode=device.hue_gradient_mode,
yeelight_min_interval_ms=device.yeelight_min_interval_ms, yeelight_min_interval_ms=device.yeelight_min_interval_ms,
wiz_min_interval_ms=device.wiz_min_interval_ms, wiz_min_interval_ms=device.wiz_min_interval_ms,
lifx_min_interval_ms=device.lifx_min_interval_ms, lifx_min_interval_ms=device.lifx_min_interval_ms,
lifx_per_zone=device.lifx_per_zone,
govee_min_interval_ms=device.govee_min_interval_ms, govee_min_interval_ms=device.govee_min_interval_ms,
opc_channel=device.opc_channel, opc_channel=device.opc_channel,
nanoleaf_paired=bool(device.nanoleaf_token), nanoleaf_paired=bool(device.nanoleaf_token),
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms, nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
nanoleaf_per_panel=device.nanoleaf_per_panel,
spi_speed_hz=device.spi_speed_hz, spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type, spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type, chroma_device_type=device.chroma_device_type,
@@ -261,6 +264,9 @@ async def create_device(
hue_username=device_data.hue_username or "", hue_username=device_data.hue_username or "",
hue_client_key=device_data.hue_client_key or "", hue_client_key=device_data.hue_client_key or "",
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "", hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
hue_gradient_mode=(
device_data.hue_gradient_mode if device_data.hue_gradient_mode is not None else True
),
yeelight_min_interval_ms=( yeelight_min_interval_ms=(
device_data.yeelight_min_interval_ms device_data.yeelight_min_interval_ms
if device_data.yeelight_min_interval_ms is not None if device_data.yeelight_min_interval_ms is not None
@@ -276,6 +282,7 @@ async def create_device(
if device_data.lifx_min_interval_ms is not None if device_data.lifx_min_interval_ms is not None
else 50 else 50
), ),
lifx_per_zone=bool(device_data.lifx_per_zone),
govee_min_interval_ms=( govee_min_interval_ms=(
device_data.govee_min_interval_ms device_data.govee_min_interval_ms
if device_data.govee_min_interval_ms is not None if device_data.govee_min_interval_ms is not None
@@ -288,6 +295,7 @@ async def create_device(
if device_data.nanoleaf_min_interval_ms is not None if device_data.nanoleaf_min_interval_ms is not None
else 100 else 100
), ),
nanoleaf_per_panel=bool(device_data.nanoleaf_per_panel),
spi_speed_hz=device_data.spi_speed_hz or 800000, spi_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B", spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink", chroma_device_type=device_data.chroma_device_type or "chromalink",
@@ -631,13 +639,16 @@ async def update_device(
hue_username=update_data.hue_username, hue_username=update_data.hue_username,
hue_client_key=update_data.hue_client_key, hue_client_key=update_data.hue_client_key,
hue_entertainment_group_id=update_data.hue_entertainment_group_id, hue_entertainment_group_id=update_data.hue_entertainment_group_id,
hue_gradient_mode=update_data.hue_gradient_mode,
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms, yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
wiz_min_interval_ms=update_data.wiz_min_interval_ms, wiz_min_interval_ms=update_data.wiz_min_interval_ms,
lifx_min_interval_ms=update_data.lifx_min_interval_ms, lifx_min_interval_ms=update_data.lifx_min_interval_ms,
lifx_per_zone=update_data.lifx_per_zone,
govee_min_interval_ms=update_data.govee_min_interval_ms, govee_min_interval_ms=update_data.govee_min_interval_ms,
opc_channel=update_data.opc_channel, opc_channel=update_data.opc_channel,
nanoleaf_token=update_data.nanoleaf_token, nanoleaf_token=update_data.nanoleaf_token,
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms, nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
nanoleaf_per_panel=update_data.nanoleaf_per_panel,
spi_speed_hz=update_data.spi_speed_hz, spi_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type, spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type, chroma_device_type=update_data.chroma_device_type,
@@ -17,6 +17,7 @@ from ledgrab.api.dependencies import (
get_database, get_database,
get_game_integration_store, get_game_integration_store,
get_game_event_bus, get_game_event_bus,
get_lol_poll_manager,
) )
from ledgrab.api.schemas.game_integration import ( from ledgrab.api.schemas.game_integration import (
AdapterInfoResponse, AdapterInfoResponse,
@@ -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.adapter_registry import AdapterRegistry
from ledgrab.core.game_integration.event_bus import GameEventBus from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.events import GameEvent from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
from ledgrab.core.game_integration.runtime_state import (
cleanup_state as _cleanup_state,
get_prev_state as _get_prev_state,
get_stats as _get_stats,
record_events as _record_events,
set_prev_state as _set_prev_state,
)
from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.game_integration import EventMapping from ledgrab.storage.game_integration import _SECRET_CONFIG_KEYS, EventMapping
from ledgrab.storage.game_integration_store import GameIntegrationStore from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
@@ -47,15 +55,10 @@ logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
# ── Per-integration runtime state (in-memory, not persisted) ────────────── # Per-integration runtime state (prev-state + stats + payload processing) lives
# in ``core/game_integration/runtime_state.py`` and is imported above under the
_integration_state_lock = threading.Lock() # legacy ``_get_prev_state`` / ``_record_events`` / … names so both this route
# and the LoL poll manager share one set of counters.
# 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]] = {}
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ───── # ── 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 return fields
def _get_prev_state(integration_id: str) -> dict[str, Any]:
"""Get or create the prev_state dict for an integration."""
with _integration_state_lock:
if integration_id not in _prev_states:
_prev_states[integration_id] = {}
return _prev_states[integration_id]
def _set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
"""Update the prev_state dict for an integration."""
with _integration_state_lock:
_prev_states[integration_id] = state
def _record_events(integration_id: str, events: list[GameEvent]) -> None:
"""Record event stats for an integration."""
with _integration_state_lock:
if integration_id not in _integration_stats:
_integration_stats[integration_id] = {
"event_count": 0,
"event_counts_by_type": {},
"last_event_time": None,
}
stats = _integration_stats[integration_id]
for event in events:
stats["event_count"] += 1
stats["event_counts_by_type"][event.event_type] = (
stats["event_counts_by_type"].get(event.event_type, 0) + 1
)
stats["last_event_time"] = event.timestamp
def _get_stats(integration_id: str) -> dict[str, Any]:
"""Get runtime stats for an integration."""
with _integration_state_lock:
return _integration_stats.get(
integration_id,
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
)
def _cleanup_state(integration_id: str) -> None:
"""Remove runtime state for a deleted integration."""
with _integration_state_lock:
_prev_states.pop(integration_id, None)
_integration_stats.pop(integration_id, None)
# ── Helper: convert config to response ──────────────────────────────────── # ── Helper: convert config to response ────────────────────────────────────
def _redact_secrets(adapter_config: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of *adapter_config* with secret values masked.
The adapter ``auth_token`` is a live shared secret (it authenticates the
ingest endpoint). It is encrypted at rest, but the response builder echoes
the in-memory *decrypted* config, so without masking any API caller
(loopback-anonymous by default) could read the cleartext token. We never
return the secret over the API — the edit form submits a blank value to
keep the existing secret (see ``_merge_preserved_secrets``).
"""
cfg = dict(adapter_config)
for key in _SECRET_CONFIG_KEYS:
if cfg.get(key):
cfg[key] = "" # mask — never echo the secret to the client
return cfg
def _merge_preserved_secrets(
incoming: dict[str, Any] | None, existing: Any
) -> dict[str, Any] | None:
"""Preserve a stored secret when an update submits a blank/absent one.
Because the API masks secrets in responses, the edit form re-submits a
blank value for an unchanged secret. Without this merge that blank would
overwrite (and destroy) the stored token. A non-empty incoming value is a
deliberate change and is kept as-is.
"""
if incoming is None:
return None
merged = dict(incoming)
for key in _SECRET_CONFIG_KEYS:
if not merged.get(key) and existing.adapter_config.get(key):
merged[key] = existing.adapter_config[key]
return merged
def _config_to_response(config: Any) -> GameIntegrationResponse: def _config_to_response(config: Any) -> GameIntegrationResponse:
"""Convert a GameIntegrationConfig to its API response.""" """Convert a GameIntegrationConfig to its API response (secrets redacted)."""
from ledgrab.api.schemas.game_integration import EventMappingSchema from ledgrab.api.schemas.game_integration import EventMappingSchema
return GameIntegrationResponse( return GameIntegrationResponse(
@@ -210,7 +201,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
name=config.name, name=config.name,
adapter_type=config.adapter_type, adapter_type=config.adapter_type,
enabled=config.enabled, enabled=config.enabled,
adapter_config=config.adapter_config, adapter_config=_redact_secrets(config.adapter_config),
event_mappings=[ event_mappings=[
EventMappingSchema( EventMappingSchema(
event_type=m.event_type, event_type=m.event_type,
@@ -302,6 +293,7 @@ async def create_integration(
data: GameIntegrationCreate, data: GameIntegrationCreate,
_auth: AuthRequired, _auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store), store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
): ):
"""Create a new game integration config.""" """Create a new game integration config."""
try: try:
@@ -330,6 +322,8 @@ async def create_integration(
) )
fire_entity_event("game_integration", "created", config.id) fire_entity_event("game_integration", "created", config.id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
return _config_to_response(config) return _config_to_response(config)
except EntityNotFoundError as e: except EntityNotFoundError as e:
@@ -369,6 +363,7 @@ async def update_integration(
data: GameIntegrationUpdate, data: GameIntegrationUpdate,
_auth: AuthRequired, _auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store), store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
): ):
"""Update a game integration config.""" """Update a game integration config."""
try: try:
@@ -386,12 +381,20 @@ async def update_integration(
for m in data.event_mappings for m in data.event_mappings
] ]
# Preserve a stored secret when the update submits a blank token
# (the API masks secrets, so the edit form re-sends a blank value
# for an unchanged secret — see _merge_preserved_secrets).
adapter_config = data.adapter_config
if adapter_config is not None:
existing = store.get_integration(integration_id)
adapter_config = _merge_preserved_secrets(adapter_config, existing)
config = store.update_integration( config = store.update_integration(
integration_id=integration_id, integration_id=integration_id,
name=data.name, name=data.name,
adapter_type=data.adapter_type, adapter_type=data.adapter_type,
enabled=data.enabled, enabled=data.enabled,
adapter_config=data.adapter_config, adapter_config=adapter_config,
event_mappings=mappings, event_mappings=mappings,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
@@ -400,6 +403,8 @@ async def update_integration(
) )
fire_entity_event("game_integration", "updated", integration_id) fire_entity_event("game_integration", "updated", integration_id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
return _config_to_response(config) return _config_to_response(config)
except EntityNotFoundError as e: except EntityNotFoundError as e:
@@ -420,11 +425,14 @@ async def delete_integration(
integration_id: str, integration_id: str,
_auth: AuthRequired, _auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store), store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
): ):
"""Delete a game integration config.""" """Delete a game integration config."""
try: try:
store.delete_integration(integration_id) store.delete_integration(integration_id)
_cleanup_state(integration_id) _cleanup_state(integration_id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
fire_entity_event("game_integration", "deleted", integration_id) fire_entity_event("game_integration", "deleted", integration_id)
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -479,9 +487,17 @@ async def ingest_event(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
# Adapter-level auth check # Adapter-level auth check. Treat ANY exception from validate_auth as an
# auth failure (rate-limited + 403), never a 500 — a malformed/attacker-
# controlled token must not crash the handler nor bypass the brute-force
# lockout counter.
headers = dict(request.headers) headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config): try:
authed = adapter_cls.validate_auth(headers, payload.data, config.adapter_config)
except Exception as exc:
logger.warning("validate_auth raised for %s: %s", integration_id, exc)
authed = False
if not authed:
_record_auth_failure(client_ip) _record_auth_failure(client_ip)
raise HTTPException(status_code=403, detail="Adapter authentication failed") raise HTTPException(status_code=403, detail="Adapter authentication failed")
+13
View File
@@ -42,6 +42,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
password_set=bool(source.password), password_set=bool(source.password),
client_id=source.client_id, client_id=source.client_id,
base_topic=source.base_topic, base_topic=source.base_topic,
publish_ha_discovery=getattr(source, "publish_ha_discovery", False),
discovery_prefix=getattr(source, "discovery_prefix", "homeassistant"),
connected=runtime.is_connected if runtime else False, connected=runtime.is_connected if runtime else False,
description=source.description, description=source.description,
tags=source.tags, tags=source.tags,
@@ -90,6 +92,8 @@ async def create_mqtt_source(
password=data.password, password=data.password,
client_id=data.client_id, client_id=data.client_id,
base_topic=data.base_topic, base_topic=data.base_topic,
publish_ha_discovery=data.publish_ha_discovery,
discovery_prefix=data.discovery_prefix,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon, icon=data.icon,
@@ -97,6 +101,8 @@ async def create_mqtt_source(
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
# Publish HA discovery if the new source opted in.
await manager.sync_discovery(source.id)
fire_entity_event("mqtt_source", "created", source.id) fire_entity_event("mqtt_source", "created", source.id)
return _to_response(source, manager) return _to_response(source, manager)
@@ -141,6 +147,8 @@ async def update_mqtt_source(
password=data.password, password=data.password,
client_id=data.client_id, client_id=data.client_id,
base_topic=data.base_topic, base_topic=data.base_topic,
publish_ha_discovery=data.publish_ha_discovery,
discovery_prefix=data.discovery_prefix,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
icon=data.icon, icon=data.icon,
@@ -151,6 +159,8 @@ async def update_mqtt_source(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
await manager.update_source(source_id) await manager.update_source(source_id)
# Reconcile HA discovery (publish if enabled, clear if turned off).
await manager.sync_discovery(source_id)
fire_entity_event("mqtt_source", "updated", source.id) fire_entity_event("mqtt_source", "updated", source.id)
return _to_response(source, manager) return _to_response(source, manager)
@@ -162,6 +172,9 @@ async def delete_mqtt_source(
store: MQTTSourceStore = Depends(get_mqtt_store), store: MQTTSourceStore = Depends(get_mqtt_store),
manager: MQTTManager = Depends(get_mqtt_manager), manager: MQTTManager = Depends(get_mqtt_manager),
): ):
# Clear any HA discovery configs (needs the source still present to build
# the exact retained topics) before deleting the row.
await manager.disable_discovery(source_id)
try: try:
store.delete_source(source_id) store.delete_source(source_id)
except EntityNotFoundError: except EntityNotFoundError:
+67 -1
View File
@@ -36,7 +36,29 @@ class RuleSchema(BaseModel):
) )
timezone: str | None = Field( timezone: str | None = Field(
None, None,
description="IANA timezone for time_of_day rule (e.g. 'Europe/Berlin'). Empty = server local.", description=(
"IANA timezone for time_of_day / solar rules (e.g. 'Europe/Berlin'). "
"Empty = server local."
),
)
# Solar rule fields (days_of_week / timezone above are shared with time_of_day)
start_event: str | None = Field(
None, description="'sunrise' or 'sunset' — window start anchor (for solar rule)"
)
start_offset_minutes: int | None = Field(
None, description="Minutes added to the start event, ±1439 (for solar rule)"
)
end_event: str | None = Field(
None, description="'sunrise' or 'sunset' — window end anchor (for solar rule)"
)
end_offset_minutes: int | None = Field(
None, description="Minutes added to the end event, ±1439 (for solar rule)"
)
latitude: float | None = Field(
None, description="Latitude for solar timing, -90..90 (for solar rule)"
)
longitude: float | None = Field(
None, description="Longitude for solar timing, -180..180 (for solar rule)"
) )
# System idle rule fields # System idle rule fields
idle_minutes: int | None = Field( idle_minutes: int | None = Field(
@@ -86,6 +108,31 @@ class RuleSchema(BaseModel):
ConditionSchema = RuleSchema ConditionSchema = RuleSchema
class ActionSchema(BaseModel):
"""A single outbound action fired alongside scene activation/deactivation."""
action_type: str = Field(description="Action type discriminator (e.g. 'webhook')")
# Webhook action fields
webhook_url: str | None = Field(
None, max_length=2048, description="Target URL for the webhook action"
)
method: str | None = Field(None, description="'POST', 'PUT', or 'GET' (for webhook action)")
body_template: str | None = Field(
None,
max_length=8192,
description=(
"Request body template (for webhook action). Tokens: {{automation_name}}, "
"{{automation_id}}, {{event}}, {{timestamp}}."
),
)
content_type: str | None = Field(
None, description="Content-Type header for the webhook body (default application/json)"
)
fire_on: str | None = Field(
None, description="'activate', 'deactivate', or 'both' (for webhook action)"
)
class AutomationCreate(BaseModel): class AutomationCreate(BaseModel):
"""Request to create an automation.""" """Request to create an automation."""
@@ -101,6 +148,9 @@ class AutomationCreate(BaseModel):
None, description="Scene preset for fallback deactivation" None, description="Scene preset for fallback deactivation"
) )
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
actions: List[ActionSchema] = Field(
default_factory=list, description="Outbound actions (e.g. webhooks)"
)
icon: str | None = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
@@ -126,6 +176,7 @@ class AutomationUpdate(BaseModel):
None, description="Scene preset for fallback deactivation" None, description="Scene preset for fallback deactivation"
) )
tags: List[str] | None = None tags: List[str] | None = None
actions: List[ActionSchema] | None = Field(None, description="Outbound actions (e.g. webhooks)")
icon: str | None = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
@@ -150,6 +201,9 @@ class AutomationResponse(BaseModel):
deactivation_mode: str = Field(default="none", description="Deactivation behavior") deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset") deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
actions: List[ActionSchema] = Field(
default_factory=list, description="Outbound actions (e.g. webhooks)"
)
webhook_url: str | None = Field( webhook_url: str | None = Field(
None, description="Webhook URL for the first webhook rule (if any)" None, description="Webhook URL for the first webhook rule (if any)"
) )
@@ -179,3 +233,15 @@ class AutomationListResponse(BaseModel):
automations: List[AutomationResponse] = Field(description="List of automations") automations: List[AutomationResponse] = Field(description="List of automations")
count: int = Field(description="Number of automations") count: int = Field(description="Number of automations")
class AutomationTriggerResponse(BaseModel):
"""Result of manually triggering an automation."""
status: str = Field(
description="'triggered' (scene applied / nothing to apply), 'partial' "
"(applied with errors), 'skipped' (rules not satisfied), or 'error'."
)
errors: List[str] = Field(
default_factory=list, description="Per-target error messages, if any."
)
@@ -145,6 +145,10 @@ class EffectCSSResponse(_CSSResponseBase):
scale: Any = Field(description="Spatial scale") scale: Any = Field(description="Spatial scale")
mirror: bool = Field(description="Mirror/bounce mode") mirror: bool = Field(description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops") custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool = Field(False, description="Modulate output by live audio loudness")
reactive_audio_source_id: str = Field("", description="AudioSource id driving reactivity")
reactive_mode: str = Field("brightness", description="brightness | saturation | both")
reactive_intensity: Any = Field(None, description="Reactive modulation strength (0-1)")
class CompositeCSSResponse(_CSSResponseBase): class CompositeCSSResponse(_CSSResponseBase):
@@ -332,6 +336,10 @@ class EffectCSSCreate(_CSSCreateBase):
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)") scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: bool | None = Field(None, description="Mirror/bounce mode") mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops") custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
reactive_mode: str | None = Field(None, description="brightness | saturation | both")
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
class CompositeCSSCreate(_CSSCreateBase): class CompositeCSSCreate(_CSSCreateBase):
@@ -532,6 +540,10 @@ class EffectCSSUpdate(_CSSUpdateBase):
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)") scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: bool | None = Field(None, description="Mirror/bounce mode") mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops") custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
reactive_mode: str | None = Field(None, description="brightness | saturation | both")
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
class CompositeCSSUpdate(_CSSUpdateBase): class CompositeCSSUpdate(_CSSUpdateBase):
+40
View File
@@ -59,6 +59,10 @@ class DeviceCreate(BaseModel):
hue_entertainment_group_id: str | None = Field( hue_entertainment_group_id: str | None = Field(
None, description="Hue entertainment group/zone ID" None, description="Hue entertainment group/zone ID"
) )
hue_gradient_mode: bool | None = Field(
None,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
# Yeelight fields # Yeelight fields
yeelight_min_interval_ms: int | None = Field( yeelight_min_interval_ms: int | None = Field(
None, None,
@@ -80,6 +84,10 @@ class DeviceCreate(BaseModel):
le=10000, le=10000,
description="LIFX client-side rate limit between commands in ms (default 50)", description="LIFX client-side rate limit between commands in ms (default 50)",
) )
lifx_per_zone: bool | None = Field(
None,
description="Stream individual zones/tiles (multizone Z/Beam, Tile/Canvas) vs single colour",
)
# Govee fields # Govee fields
govee_min_interval_ms: int | None = Field( govee_min_interval_ms: int | None = Field(
None, None,
@@ -106,6 +114,9 @@ class DeviceCreate(BaseModel):
le=10000, le=10000,
description="Nanoleaf client-side rate limit between commands in ms (default 100)", description="Nanoleaf client-side rate limit between commands in ms (default 100)",
) )
nanoleaf_per_panel: bool | None = Field(
None, description="Stream each panel individually via extControl UDP (vs single colour)"
)
# SPI Direct fields # SPI Direct fields
spi_speed_hz: int | None = Field( spi_speed_hz: int | None = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz" None, ge=100000, le=4000000, description="SPI clock speed in Hz"
@@ -195,6 +206,10 @@ class DeviceUpdate(BaseModel):
hue_username: str | None = Field(None, description="Hue bridge username") hue_username: str | None = Field(None, description="Hue bridge username")
hue_client_key: str | None = Field(None, description="Hue entertainment client key") hue_client_key: str | None = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID") hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
hue_gradient_mode: bool | None = Field(
None,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
yeelight_min_interval_ms: int | None = Field( yeelight_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms" None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
) )
@@ -204,6 +219,9 @@ class DeviceUpdate(BaseModel):
lifx_min_interval_ms: int | None = Field( lifx_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms" None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
) )
lifx_per_zone: bool | None = Field(
None, description="Stream individual zones/tiles (multizone/matrix) vs single colour"
)
govee_min_interval_ms: int | None = Field( govee_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Govee client-side rate limit in ms" None, ge=0, le=10000, description="Govee client-side rate limit in ms"
) )
@@ -212,6 +230,9 @@ class DeviceUpdate(BaseModel):
nanoleaf_min_interval_ms: int | None = Field( nanoleaf_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms" None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
) )
nanoleaf_per_panel: bool | None = Field(
None, description="Stream each panel individually via extControl UDP"
)
spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed") spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: str | None = Field(None, description="LED chipset type") spi_led_type: str | None = Field(None, description="LED chipset type")
chroma_device_type: str | None = Field(None, description="Chroma peripheral type") chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
@@ -356,6 +377,14 @@ class Calibration(BaseModel):
roi_height: float = Field( roi_height: float = Field(
default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)" default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)"
) )
linear_blend: bool = Field(
default=False,
description="Blend border pixels in linear light instead of sRGB (perceptually correct)",
)
dither: bool = Field(
default=False,
description="Spatio-temporally dither the final 8-bit output to reduce gradient banding",
)
class CalibrationTestModeRequest(BaseModel): class CalibrationTestModeRequest(BaseModel):
@@ -428,11 +457,19 @@ class DeviceResponse(BaseModel):
), ),
) )
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID") hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
hue_gradient_mode: bool = Field(
default=True,
description="Map the strip across gradient-lightstrip channels vs one record per light",
)
yeelight_min_interval_ms: int = Field( yeelight_min_interval_ms: int = Field(
default=500, description="Yeelight client-side rate limit in ms" default=500, description="Yeelight client-side rate limit in ms"
) )
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms") wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms") lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
lifx_per_zone: bool = Field(
default=False,
description="Stream individual zones/tiles (multizone/matrix) vs single colour",
)
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms") govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)") opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
nanoleaf_paired: bool = Field( nanoleaf_paired: bool = Field(
@@ -446,6 +483,9 @@ class DeviceResponse(BaseModel):
nanoleaf_min_interval_ms: int = Field( nanoleaf_min_interval_ms: int = Field(
default=100, description="Nanoleaf client-side rate limit in ms" default=100, description="Nanoleaf client-side rate limit in ms"
) )
nanoleaf_per_panel: bool = Field(
default=False, description="Stream each panel individually via extControl UDP"
)
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz") spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type") spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type") chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
+12
View File
@@ -16,6 +16,12 @@ class MQTTSourceCreate(BaseModel):
password: str = Field(default="", description="Broker password (optional)") password: str = Field(default="", description="Broker password (optional)")
client_id: str = Field(default="ledgrab", description="MQTT client ID") client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix") base_topic: str = Field(default="ledgrab", description="Base topic prefix")
publish_ha_discovery: bool = Field(
default=False, description="Publish Home Assistant MQTT auto-discovery configs"
)
discovery_prefix: str = Field(
default="homeassistant", description="HA MQTT discovery prefix (default 'homeassistant')"
)
description: str | None = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: str | None = Field( icon: str | None = Field(
@@ -40,6 +46,10 @@ class MQTTSourceUpdate(BaseModel):
password: str | None = Field(None, description="Broker password") password: str | None = Field(None, description="Broker password")
client_id: str | None = Field(None, description="MQTT client ID") client_id: str | None = Field(None, description="MQTT client ID")
base_topic: str | None = Field(None, description="Base topic prefix") base_topic: str | None = Field(None, description="Base topic prefix")
publish_ha_discovery: bool | None = Field(
None, description="Publish Home Assistant MQTT auto-discovery configs"
)
discovery_prefix: str | None = Field(None, description="HA MQTT discovery prefix")
description: str | None = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None tags: List[str] | None = None
icon: str | None = Field( icon: str | None = Field(
@@ -65,6 +75,8 @@ class MQTTSourceResponse(BaseModel):
password_set: bool = Field(default=False, description="Whether a password is configured") password_set: bool = Field(default=False, description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID") client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix") base_topic: str = Field(description="Base topic prefix")
publish_ha_discovery: bool = Field(default=False, description="HA MQTT discovery enabled")
discovery_prefix: str = Field(default="homeassistant", description="HA MQTT discovery prefix")
connected: bool = Field(default=False, description="Whether the broker connection is active") connected: bool = Field(default=False, description="Whether the broker connection is active")
description: str | None = Field(None, description="Description") description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -1,18 +1,31 @@
"""Actor context variable for the activity log. """Actor context variable for the activity log.
``current_actor`` is set by ``api/auth.py:verify_api_key`` on every request so ``current_actor`` is set by ``api/auth.py:verify_api_key`` so that
that ``ActivityRecorder.record(...)`` can resolve the actor without requiring ``ActivityRecorder.record(...)`` can resolve the actor without requiring every
every call site to pass it explicitly. call site to pass it explicitly.
Default value is ``"system"`` — used by background engines and any code path Default value is ``"system"`` — used by background engines and any code path
that runs outside a request context (e.g. lifespan startup/shutdown, zeroconf that runs outside a request context (e.g. lifespan startup/shutdown, zeroconf
discovery thread). discovery thread).
Per-request isolation is guaranteed by ASGI's coroutine context: each request Per-request isolation is provided by ASGI/anyio ContextVar copy semantics:
runs in its own coroutine with its own copy of the context inherited from the Starlette dispatches each request in its own task whose context is a copy of
server's main task. The auth layer resets it on every request before the route the parent, so a ``current_actor.set(...)`` in one request is never visible to
handler runs, so stale labels from a previous request cannot bleed into a new another request, and each request starts from the ``"system"`` default.
one.
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 from contextvars import ContextVar
@@ -45,8 +45,14 @@ logger = get_logger(__name__)
def _new_id() -> str: def _new_id() -> str:
"""Generate a compact activity-log entry id: ``al_<8-hex-chars>``.""" """Generate an activity-log entry id: ``al_<32-hex-chars>``.
return "al_" + uuid.uuid4().hex[:8]
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: def entry_to_dict(entry: ActivityLogEntry) -> dict:
@@ -74,9 +74,17 @@ def sanitize_display(value: str | None, *, maxlen: int = 120) -> str:
# that may survive if isprintable ever changes in a future Python version). # 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) 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: if len(cleaned) > maxlen:
# Reserve one character for the ellipsis so total length == maxlen. if maxlen == 1:
cleaned = cleaned[: 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 return cleaned
@@ -13,8 +13,10 @@ from ledgrab.storage.automation import (
DisplayStateRule, DisplayStateRule,
HomeAssistantRule, HomeAssistantRule,
HTTPPollRule, HTTPPollRule,
ManualTriggerRule,
MQTTRule, MQTTRule,
Rule, Rule,
SolarRule,
StartupRule, StartupRule,
SystemIdleRule, SystemIdleRule,
TimeOfDayRule, TimeOfDayRule,
@@ -23,6 +25,7 @@ from ledgrab.storage.automation import (
from ledgrab.storage.automation_store import AutomationStore from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset import ScenePreset from ledgrab.storage.scene_preset import ScenePreset
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.solar import compute_solar_times, utc_offset_hours_for
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -141,6 +144,11 @@ class AutomationEngine:
self._last_deactivated: Dict[str, datetime] = {} self._last_deactivated: Dict[str, datetime] = {}
# webhook_token → bool (volatile state set by webhook calls) # webhook_token → bool (volatile state set by webhook calls)
self._webhook_states: Dict[str, bool] = {} self._webhook_states: Dict[str, bool] = {}
# True only while a single automation is being manually fired
# (fire_manual_trigger). The background tick never sets it, so a
# ManualTriggerRule reads False during normal evaluation and a
# manual-trigger automation never activates on its own.
self._manual_fire_active: bool = False
# HA source IDs currently acquired by the engine # HA source IDs currently acquired by the engine
self._ha_acquired: Set[str] = set() self._ha_acquired: Set[str] = set()
# MQTT source IDs currently acquired by the engine # MQTT source IDs currently acquired by the engine
@@ -369,6 +377,32 @@ class AutomationEngine:
display_state, display_state,
) )
@staticmethod
def _detection_needs(rules) -> tuple[bool, bool, bool, bool, bool]:
"""Which platform-detection probes a set of rules requires.
Returns ``(needs_running, needs_topmost, needs_fullscreen, needs_idle,
needs_display_state)``. Shared by the background evaluation tick and the
one-shot manual-trigger path so both request the same detection set.
"""
match_types_used: set = set()
needs_idle = False
needs_display_state = False
for r in rules:
if isinstance(r, ApplicationRule):
match_types_used.add(r.match_type)
elif isinstance(r, SystemIdleRule):
needs_idle = True
elif isinstance(r, DisplayStateRule):
needs_display_state = True
return (
"running" in match_types_used,
bool(match_types_used & {"topmost", "topmost_fullscreen"}),
"fullscreen" in match_types_used,
needs_idle,
needs_display_state,
)
async def _evaluate_all_locked(self) -> None: async def _evaluate_all_locked(self) -> None:
automations = self._store.get_all_automations() automations = self._store.get_all_automations()
if not automations: if not automations:
@@ -377,23 +411,15 @@ class AutomationEngine:
await self._deactivate_automation(aid) await self._deactivate_automation(aid)
return return
# Determine which detection methods are actually needed # Determine which detection methods are actually needed (across the
match_types_used: set = set() # rules of every *enabled* automation — disabled ones are skipped below).
needs_idle = False (
needs_display_state = False needs_running,
for a in automations: needs_topmost,
if a.enabled: needs_fullscreen,
for r in a.rules: needs_idle,
if isinstance(r, ApplicationRule): needs_display_state,
match_types_used.add(r.match_type) ) = self._detection_needs([r for a in automations if a.enabled for r in a.rules])
elif isinstance(r, SystemIdleRule):
needs_idle = True
elif isinstance(r, DisplayStateRule):
needs_display_state = True
needs_running = "running" in match_types_used
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
needs_fullscreen = "fullscreen" in match_types_used
# Single executor call for all platform detection # Single executor call for all platform detection
( (
@@ -526,6 +552,9 @@ class AutomationEngine:
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool: def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_time_of_day(rule) return self._evaluate_time_of_day(rule)
def _handle_solar(self, rule: SolarRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_solar(rule)
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool: def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_idle(rule, ctx.idle_seconds) return self._evaluate_idle(rule, ctx.idle_seconds)
@@ -538,12 +567,40 @@ class AutomationEngine:
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool: def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
return self._webhook_states.get(rule.token, False) return self._webhook_states.get(rule.token, False)
def _handle_manual(self, rule: ManualTriggerRule, ctx: _RuleEvalContext) -> bool:
# True only while fire_manual_trigger is evaluating this one automation
# under the eval lock; always False during the background tick.
return self._manual_fire_active
def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool: def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_home_assistant(rule) return self._evaluate_home_assistant(rule)
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool: def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_http_poll(rule) return self._evaluate_http_poll(rule)
@staticmethod
def _weekday_window_active(
current: int, start: int, end: int, weekday: int, days: list
) -> bool:
"""Is ``current`` (minutes-of-day) inside the [start, end] window?
Handles the overnight wrap (start > end): the after-midnight tail
belongs to the window's START day, so it's matched against the
previous weekday. ``days`` empty = every day of the week.
"""
if start <= end:
if not (start <= current <= end):
return False
return not days or weekday in days
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its
# START day, so the after-midnight tail is matched against yesterday.
if current >= start: # evening portion — today's window
return not days or weekday in days
if current <= end: # early-morning portion — yesterday's window
return not days or ((weekday - 1) % 7) in days
return False
@staticmethod @staticmethod
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool: def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
now = _now_in_tz(rule.timezone) now = _now_in_tz(rule.timezone)
@@ -552,20 +609,34 @@ class AutomationEngine:
parts_e = rule.end_time.split(":") parts_e = rule.end_time.split(":")
start = int(parts_s[0]) * 60 + int(parts_s[1]) start = int(parts_s[0]) * 60 + int(parts_s[1])
end = int(parts_e[0]) * 60 + int(parts_e[1]) end = int(parts_e[0]) * 60 + int(parts_e[1])
days = rule.days_of_week return AutomationEngine._weekday_window_active(
current, start, end, now.weekday(), rule.days_of_week
)
if start <= end: @staticmethod
if not (start <= current <= end): def _evaluate_solar(rule: SolarRule) -> bool:
return False # One ``now`` drives every read: day-of-year, the UTC offset for the
return not days or now.weekday() in days # solar math, the current-minute compare, and the weekday.
now = _now_in_tz(rule.timezone)
day_of_year = now.timetuple().tm_yday
utc_offset = utc_offset_hours_for(rule.timezone, now)
sunrise_h, sunset_h = compute_solar_times(
rule.latitude, rule.longitude, day_of_year, utc_offset
)
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its def _event_minutes(event: str) -> int:
# START day, so the after-midnight tail is matched against yesterday. hour = sunset_h if event == "sunset" else sunrise_h
if current >= start: # evening portion — today's window return int(round(hour * 60))
return not days or now.weekday() in days
if current <= end: # early-morning portion — yesterday's window # compute_solar_times clamps sunrise < sunset, so the only way to wrap
return not days or ((now.weekday() - 1) % 7) in days # past midnight is via the offsets — which ``_weekday_window_active``
return False # handles the same way it does an overnight time-of-day window.
start = (_event_minutes(rule.start_event) + rule.start_offset_minutes) % 1440
end = (_event_minutes(rule.end_event) + rule.end_offset_minutes) % 1440
current = now.hour * 60 + now.minute
return AutomationEngine._weekday_window_active(
current, start, end, now.weekday(), rule.days_of_week
)
@staticmethod @staticmethod
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool: def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
@@ -675,6 +746,62 @@ class AutomationEngine:
# Default: "running" # Default: "running"
return any(app in running_procs for app in apps_lower) return any(app in running_procs for app in apps_lower)
def _audit_activation(self, automation: Automation) -> None:
"""Best-effort audit record for any successful automation activation.
Shared by both the normal scene path and the no-scene branch so an
activation is recorded uniformly regardless of whether a scene was
applied (mirrors the uniform recording on the deactivation side).
"""
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_name = sanitize_display(automation.name) if automation.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.activated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' activated",
)
except Exception:
pass
def _audit_manual_trigger(self, automation: Automation) -> None:
"""Best-effort audit record for a manual trigger.
Unlike :meth:`_audit_activation` this does NOT force ``actor='system'``
— the recorder resolves ``actor`` from the ``current_actor`` ContextVar
(set in ``verify_api_key``), so the run is attributed to the user who
pressed the button.
"""
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_name = sanitize_display(automation.name) if automation.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.triggered",
severity=ActivitySeverity.INFO,
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' manually triggered",
)
except Exception:
pass
async def _activate_automation(self, automation: Automation) -> None: async def _activate_automation(self, automation: Automation) -> None:
if not automation.scene_preset_id: if not automation.scene_preset_id:
# No scene configured — just mark active (rules matched but nothing to do) # No scene configured — just mark active (rules matched but nothing to do)
@@ -682,6 +809,11 @@ class AutomationEngine:
self._last_activated[automation.id] = datetime.now(timezone.utc) self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "activated") self._fire_event(automation.id, "activated")
logger.info(f"Automation '{automation.name}' activated (no scene configured)") logger.info(f"Automation '{automation.name}' activated (no scene configured)")
# Record the activation too — a no-scene activation is still a
# successful activation and must appear in the audit log.
self._audit_activation(automation)
await self._fire_actions(automation, "activate")
await self._publish_mqtt_state(automation.id, True)
return return
if not self._scene_preset_store or not self._target_store or not self._device_store: if not self._scene_preset_store or not self._target_store or not self._device_store:
@@ -726,28 +858,141 @@ class AutomationEngine:
else: else:
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)") logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
# Audit record — best-effort. # Audit record — best-effort (shared helper, also used by no-scene path).
self._audit_activation(automation)
await self._fire_actions(automation, "activate")
await self._publish_mqtt_state(automation.id, True)
async def _fire_actions(self, automation: Automation, event: str) -> None:
"""Fire any outbound actions (e.g. webhooks) for this transition.
Best-effort and never raises into the activation path: a hung or
failing endpoint is logged/audited but must not stall the evaluation
loop or abort scene activation.
"""
actions = getattr(automation, "actions", None)
if not actions:
return
from ledgrab.storage.automation import WebhookAction
from ledgrab.core.automations.webhook_action import fire_webhook_action, should_fire
for action in actions:
if not isinstance(action, WebhookAction) or not should_fire(action, event):
continue
try:
ok, err = await fire_webhook_action(action, automation, event)
except Exception as exc: # noqa: BLE001 — defensive; fire is already best-effort
logger.warning(
"Action fire raised for '%s': %s", automation.name, type(exc).__name__
)
ok, err = False, type(exc).__name__
self._audit_webhook(automation, event, ok, err)
def _audit_webhook(self, automation: Automation, event: str, ok: bool, err: str | None) -> None:
"""Best-effort audit entry for a webhook fire (success or failure)."""
try: try:
from ledgrab.core.activity_log.recorder import get_module_recorder from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder() rec = get_module_recorder()
if rec is not None: if rec is None:
_safe_name = sanitize_display(automation.name) if automation.name else None return
rec.record( safe_name = sanitize_display(automation.name) if automation.name else automation.id
category=ActivityCategory.CAPTURE, rec.record(
action="automation.activated", category=ActivityCategory.CAPTURE,
severity=ActivitySeverity.INFO, action="automation.webhook_fired",
actor="system", severity=ActivitySeverity.INFO if ok else ActivitySeverity.WARNING,
entity_type="automation", actor="system",
entity_id=automation.id, entity_type="automation",
entity_name=_safe_name, entity_id=automation.id,
message=f"Automation '{_safe_name or automation.id}' activated", entity_name=safe_name,
) message=(
f"Webhook for '{safe_name}' {'fired' if ok else 'failed'} on {event}"
+ ("" if ok else f" ({sanitize_display(err) if err else 'error'})")
),
)
except Exception: except Exception:
pass pass
async def _apply_manual_scene(self, automation: Automation) -> tuple[str, list[str]]:
"""Apply the automation's scene once for a manual trigger.
Mirrors the scene-application core of :meth:`_activate_automation` but
does NOT enter the sticky ``_active_automations`` state or capture a
revert snapshot — a manual trigger is a one-shot apply, so the
background tick has nothing to reconcile away. Returns
``(status, errors)`` where ``status`` is ``"triggered"`` (applied, or no
scene configured), ``"partial"`` (applied with errors), or ``"error"``
(scene stores unavailable / preset missing).
"""
if not automation.scene_preset_id:
return ("triggered", [])
if not self._scene_preset_store or not self._target_store or not self._device_store:
logger.warning(
f"Automation '{automation.name}' triggered but scene stores not available"
)
return ("error", ["scene stores not available"])
try:
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
except ValueError:
logger.warning(
f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found"
)
return ("error", [f"scene preset {automation.scene_preset_id} not found"])
from ledgrab.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state(preset, self._target_store, self._manager)
if errors:
logger.warning(
f"Automation '{automation.name}' manually triggered with errors: {errors}"
)
else:
logger.info(
f"Automation '{automation.name}' manually triggered (scene '{preset.name}' applied)"
)
# apply_scene_state returns "activated"/"partial"; surface "triggered"
# for the happy path so the API status reads naturally.
return ("triggered" if status == "activated" else status, errors)
async def fire_manual_trigger(self, automation: Automation) -> tuple[str, list[str]]:
"""Manually fire an automation: evaluate its rules with the manual
trigger satisfied and, if it should activate, apply its scene once.
"Checks all of the rules": the automation's full rule set is evaluated
under its ``rule_logic`` with the ManualTriggerRule treated as True. The
``enabled`` flag is intentionally ignored — it gates only the background
tick; a manual trigger is an explicit user action. Returns
``(status, errors)``: ``"skipped"`` when the rules are not satisfied,
otherwise the result of :meth:`_apply_manual_scene`.
"""
async with self._eval_lock:
detection = await asyncio.to_thread(
self._detect_all_sync, *self._detection_needs(automation.rules)
)
# Force the manual term True for this one evaluation, then clear it
# before releasing the lock so the background tick never sees it.
self._manual_fire_active = True
try:
should_fire = (not automation.rules) or self._evaluate_rules(automation, *detection)
finally:
self._manual_fire_active = False
if not should_fire:
logger.info(
f"Automation '{automation.name}' manual trigger skipped (rules not satisfied)"
)
return ("skipped", [])
status, errors = await self._apply_manual_scene(automation)
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "triggered")
self._audit_manual_trigger(automation)
return (status, errors)
async def _deactivate_automation(self, automation_id: str) -> None: async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_automations.pop(automation_id, False) was_active = self._active_automations.pop(automation_id, False)
if not was_active: if not was_active:
@@ -781,11 +1026,9 @@ class AutomationEngine:
rec = get_module_recorder() rec = get_module_recorder()
if rec is not None: if rec is not None:
_auto_name: str | None = None # Reuse the automation already fetched above (no second store
try: # read); degrades to None if it was since-deleted (== None).
_auto_name = self._store.get_automation(automation_id).name _auto_name = automation.name if automation else None
except Exception:
pass
_safe_deact_name = sanitize_display(_auto_name) if _auto_name else None _safe_deact_name = sanitize_display(_auto_name) if _auto_name else None
rec.record( rec.record(
category=ActivityCategory.CAPTURE, category=ActivityCategory.CAPTURE,
@@ -800,6 +1043,22 @@ class AutomationEngine:
except Exception: except Exception:
pass pass
# Fire any outbound deactivate actions (best-effort). Skipped when the
# automation was since-deleted (no actions to read).
if automation is not None:
await self._fire_actions(automation, "deactivate")
await self._publish_mqtt_state(automation_id, False)
async def _publish_mqtt_state(self, automation_id: str, active: bool) -> None:
"""Best-effort publish of the automation's active state to HA discovery."""
mgr = self._mqtt_manager
if mgr is None or not hasattr(mgr, "publish_automation_state_all"):
return
try:
await mgr.publish_automation_state_all(automation_id, active)
except Exception: # noqa: BLE001 — never raise into the engine
pass
async def _deactivate_revert(self, automation_id: str) -> None: async def _deactivate_revert(self, automation_id: str) -> None:
"""Revert to pre-activation snapshot.""" """Revert to pre-activation snapshot."""
snapshot = self._pre_activation_snapshots.pop(automation_id, None) snapshot = self._pre_activation_snapshots.pop(automation_id, None)
@@ -904,10 +1163,12 @@ AutomationEngine._RULE_HANDLERS = {
StartupRule: AutomationEngine._handle_startup, StartupRule: AutomationEngine._handle_startup,
ApplicationRule: AutomationEngine._handle_application, ApplicationRule: AutomationEngine._handle_application,
TimeOfDayRule: AutomationEngine._handle_time_of_day, TimeOfDayRule: AutomationEngine._handle_time_of_day,
SolarRule: AutomationEngine._handle_solar,
SystemIdleRule: AutomationEngine._handle_system_idle, SystemIdleRule: AutomationEngine._handle_system_idle,
DisplayStateRule: AutomationEngine._handle_display_state, DisplayStateRule: AutomationEngine._handle_display_state,
MQTTRule: AutomationEngine._handle_mqtt, MQTTRule: AutomationEngine._handle_mqtt,
WebhookRule: AutomationEngine._handle_webhook, WebhookRule: AutomationEngine._handle_webhook,
ManualTriggerRule: AutomationEngine._handle_manual,
HomeAssistantRule: AutomationEngine._handle_home_assistant, HomeAssistantRule: AutomationEngine._handle_home_assistant,
HTTPPollRule: AutomationEngine._handle_http_poll, HTTPPollRule: AutomationEngine._handle_http_poll,
} }
@@ -925,10 +1186,12 @@ def _assert_rule_handler_coverage() -> None:
StartupRule, StartupRule,
ApplicationRule, ApplicationRule,
TimeOfDayRule, TimeOfDayRule,
SolarRule,
SystemIdleRule, SystemIdleRule,
DisplayStateRule, DisplayStateRule,
MQTTRule, MQTTRule,
WebhookRule, WebhookRule,
ManualTriggerRule,
HomeAssistantRule, HomeAssistantRule,
HTTPPollRule, HTTPPollRule,
} }
@@ -0,0 +1,102 @@
"""Outbound webhook action firing for the automation engine.
When an automation activates or deactivates, any attached
:class:`~ledgrab.storage.automation.WebhookAction` performs a best-effort
outbound HTTP request (Discord / IFTTT / Zapier / Node-RED / Home Assistant
webhooks). Firing is fire-and-forget: a hung or failing endpoint is logged and
audited but never raises into the activation path.
Security: the target URL is SSRF-gated via :func:`validate_polling_url` (LAN
allowed so users can hit Node-RED / HA on their own network; loopback,
link-local / cloud-metadata, multicast and reserved ranges blocked) at **both**
save time (in the route) and fire time (here) — re-validating at fire time
closes the DNS-rebinding window. Redirects are not followed.
"""
from __future__ import annotations
from datetime import datetime, timezone
import httpx
from fastapi import HTTPException
from ledgrab.storage.automation import Automation, WebhookAction
from ledgrab.utils import get_logger
from ledgrab.utils.safe_source import validate_polling_url
logger = get_logger(__name__)
# A webhook must never stall the ~1 Hz evaluation loop.
_WEBHOOK_TIMEOUT_S = 5.0
def render_template(template: str, automation: Automation, event: str) -> str:
"""Substitute the supported ``{{token}}`` placeholders in *template*.
Tokens: ``{{automation_name}}``, ``{{automation_id}}``, ``{{event}}``
(``activate``/``deactivate``), ``{{timestamp}}`` (ISO-8601 UTC). Unknown
tokens are left untouched.
"""
replacements = {
"{{automation_name}}": automation.name,
"{{automation_id}}": automation.id,
"{{event}}": event,
"{{timestamp}}": datetime.now(timezone.utc).isoformat(),
}
out = template
for token, value in replacements.items():
out = out.replace(token, value)
return out
def should_fire(action: WebhookAction, event: str) -> bool:
"""Whether *action* fires for this transition (``activate``/``deactivate``)."""
return action.fire_on == event or action.fire_on == "both"
async def fire_webhook_action(
action: WebhookAction,
automation: Automation,
event: str,
) -> tuple[bool, str | None]:
"""Fire a single webhook action. Best-effort: never raises.
Returns ``(ok, error)`` where ``ok`` is True on a 2xx response and
``error`` is a short, secret-free reason on failure.
"""
url = action.webhook_url.strip()
if not url:
return False, "no URL configured"
# Re-validate at fire time (DNS-rebinding window). HTTPException carries a
# 4xx detail; surface a short reason rather than raising into the engine.
try:
validate_polling_url(url)
except HTTPException as exc:
logger.warning("Webhook for '%s' blocked by SSRF policy: %s", automation.name, exc.detail)
return False, "blocked by SSRF policy"
body = render_template(action.body_template, automation, event)
headers = {"Content-Type": action.content_type or "application/json"}
try:
async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT_S, follow_redirects=False) as client:
kwargs: dict = {"headers": headers}
# Only attach a body for write methods with content to send.
if action.method in ("POST", "PUT") and body:
kwargs["content"] = body.encode("utf-8")
response = await client.request(action.method, url, **kwargs)
except Exception as exc: # noqa: BLE001 — never propagate into activation
# Never log the rendered body or the exception repr (may carry the URL
# with embedded secrets) — the type name is enough to diagnose.
logger.warning("Webhook for '%s' failed: %s", automation.name, type(exc).__name__)
return False, f"request failed: {type(exc).__name__}"
ok = 200 <= response.status_code < 300
if not ok:
logger.warning("Webhook for '%s' returned HTTP %d", automation.name, response.status_code)
return False, f"HTTP {response.status_code}"
logger.info(
"Webhook for '%s' fired on %s (HTTP %d)", automation.name, event, response.status_code
)
return True, None
+42 -2
View File
@@ -120,6 +120,11 @@ class CalibrationConfig:
roi_y: float = 0.0 roi_y: float = 0.0
roi_width: float = 1.0 roi_width: float = 1.0
roi_height: float = 1.0 roi_height: float = 1.0
# Blend border pixels in linear light (perceptually correct averaging)
# instead of gamma-encoded sRGB. Off by default = unchanged behaviour.
linear_blend: bool = False
# Spatio-temporal dither the final 8-bit quantization to reduce banding.
dither: bool = False
@property @property
def has_roi(self) -> bool: def has_roi(self) -> bool:
@@ -349,6 +354,8 @@ class PixelMapper:
""" """
self.calibration = calibration self.calibration = calibration
self.interpolation_mode = interpolation_mode self.interpolation_mode = interpolation_mode
# Per-frame counter driving the temporal dither phase.
self._dither_frame = 0
# Validate calibration # Validate calibration
self.calibration.validate() self.calibration.validate()
@@ -430,7 +437,16 @@ class PixelMapper:
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name; Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
the shared kernel handles all allocations on first use. the shared kernel handles all allocations on first use.
""" """
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, edge_name) return average_edge_to_leds(
edge_pixels,
edge_name,
led_count,
self._edge_cache,
edge_name,
linear=self.calibration.linear_blend,
dither=self.calibration.dither,
frame_index=self._dither_frame,
)
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray: def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors. """Map screen border pixels to LED colors.
@@ -449,6 +465,7 @@ class PixelMapper:
""" """
led_array = self._led_buf led_array = self._led_buf
led_array[:] = 0 led_array[:] = 0
self._dither_frame += 1
# Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll) # Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll)
for i, segment in enumerate(self.calibration.segments): for i, segment in enumerate(self.calibration.segments):
@@ -514,6 +531,7 @@ class AdvancedPixelMapper:
): ):
self.calibration = calibration self.calibration = calibration
self.interpolation_mode = interpolation_mode self.interpolation_mode = interpolation_mode
self._dither_frame = 0
calibration.validate() calibration.validate()
if interpolation_mode == "average": if interpolation_mode == "average":
@@ -600,7 +618,16 @@ class AdvancedPixelMapper:
``cache_key`` is an integer (e.g. line index) so multiple per-line ``cache_key`` is an integer (e.g. line index) so multiple per-line
edges can share the same ``self._edge_cache`` dict without colliding. edges can share the same ``self._edge_cache`` dict without colliding.
""" """
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, cache_key) return average_edge_to_leds(
edge_pixels,
edge_name,
led_count,
self._edge_cache,
cache_key,
linear=self.calibration.linear_blend,
dither=self.calibration.dither,
frame_index=self._dither_frame,
)
def _map_edge_fallback( def _map_edge_fallback(
self, self,
@@ -622,6 +649,7 @@ class AdvancedPixelMapper:
""" """
led_array = self._led_buf led_array = self._led_buf
led_array[:] = 0 led_array[:] = 0
self._dither_frame += 1
for i, line in enumerate(self.calibration.lines): for i, line in enumerate(self.calibration.lines):
frame = frames.get(line.picture_source_id) frame = frames.get(line.picture_source_id)
@@ -902,6 +930,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
offset=data.get("offset", 0), offset=data.get("offset", 0),
skip_leds_start=data.get("skip_leds_start", 0), skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0), skip_leds_end=data.get("skip_leds_end", 0),
linear_blend=bool(data.get("linear_blend", False)),
dither=bool(data.get("dither", False)),
) )
config.validate() config.validate()
return config return config
@@ -931,6 +961,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
roi_y=data.get("roi_y", 0.0), roi_y=data.get("roi_y", 0.0),
roi_width=data.get("roi_width", 1.0), roi_width=data.get("roi_width", 1.0),
roi_height=data.get("roi_height", 1.0), roi_height=data.get("roi_height", 1.0),
linear_blend=bool(data.get("linear_blend", False)),
dither=bool(data.get("dither", False)),
) )
config.validate() config.validate()
@@ -975,6 +1007,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["skip_leds_start"] = config.skip_leds_start result["skip_leds_start"] = config.skip_leds_start
if config.skip_leds_end > 0: if config.skip_leds_end > 0:
result["skip_leds_end"] = config.skip_leds_end result["skip_leds_end"] = config.skip_leds_end
if config.linear_blend:
result["linear_blend"] = True
if config.dither:
result["dither"] = True
return result return result
# Simple mode # Simple mode
@@ -1008,4 +1044,8 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["roi_y"] = config.roi_y result["roi_y"] = config.roi_y
result["roi_width"] = config.roi_width result["roi_width"] = config.roi_width
result["roi_height"] = config.roi_height result["roi_height"] = config.roi_height
if config.linear_blend:
result["linear_blend"] = True
if config.dither:
result["dither"] = True
return result return result
@@ -233,8 +233,20 @@ class CalibrationSession:
self._last_activity = datetime.now(timezone.utc) self._last_activity = datetime.now(timezone.utc)
self._active = True self._active = True
# Clear the device to black so the chase starts from a clean state # Clear the device to black so the chase starts from a clean state.
await manager.send_clear_pixels(device_id) # send_clear_pixels re-raises on a double send failure; a transient
# failure here must NOT strand the session with _active=True and no
# watchdog — log and continue so the idle-timeout watchdog still gets
# armed (mirrors the guarded clear in _teardown_locked).
try:
await manager.send_clear_pixels(device_id)
except Exception as exc:
logger.warning(
"CalibrationSession.start: failed to clear pixels on %s "
"before chase (continuing): %s",
device_id,
exc,
)
# Start idle-timeout watchdog # Start idle-timeout watchdog
self._timeout_task = asyncio.ensure_future(self._idle_watchdog()) self._timeout_task = asyncio.ensure_future(self._idle_watchdog())
@@ -23,6 +23,9 @@ from typing import Any, Callable, Dict, Hashable, Tuple
import numpy as np import numpy as np
from ledgrab.utils.dither import ordered_dither_quantize
from ledgrab.utils.linear_light import linear_to_srgb_float, linear_to_srgb_uint8, srgb_to_linear
# Cache value layout — kept as a tuple for the small per-frame cost of # Cache value layout — kept as a tuple for the small per-frame cost of
# tuple unpacking vs the readability of a dataclass. The first two entries # tuple unpacking vs the readability of a dataclass. The first two entries
# are the (edge_len, led_count) signature used to detect a re-build. # are the (edge_len, led_count) signature used to detect a re-build.
@@ -75,6 +78,9 @@ def average_edge_to_leds(
led_count: int, led_count: int,
cache: Dict[Hashable, _CacheEntry], cache: Dict[Hashable, _CacheEntry],
cache_key: Hashable, cache_key: Hashable,
linear: bool = False,
dither: bool = False,
frame_index: int = 0,
) -> np.ndarray: ) -> np.ndarray:
"""Vectorised average colour per LED segment. """Vectorised average colour per LED segment.
@@ -82,6 +88,14 @@ def average_edge_to_leds(
over axis=0 (collapsing rows), then segment along the width; for over axis=0 (collapsing rows), then segment along the width; for
left/right edges we average over axis=1 then segment along the height. left/right edges we average over axis=1 then segment along the height.
When ``linear`` is True the pixels are decoded to linear light before
averaging and re-encoded to sRGB at the end — perceptually correct
blending at a small extra cost (a LUT decode of the input + an analytic
encode of the per-LED result).
When ``dither`` is True the final 8-bit quantization is spatio-temporally
dithered (using ``frame_index``) to suppress gradient banding.
Returns a view into the caller-owned cache's ``out_uint8`` buffer — Returns a view into the caller-owned cache's ``out_uint8`` buffer —
do NOT retain the result across calls without copying. do NOT retain the result across calls without copying.
""" """
@@ -110,8 +124,13 @@ def average_edge_to_leds(
out_uint8, out_uint8,
) = entry ) = entry
# Decode to linear light first so both the row/column collapse and the
# per-segment mean happen in physically-linear space. ``src`` is float32
# in [0, 1] (linear) or the raw uint8 sRGB pixels otherwise.
src = srgb_to_linear(edge_pixels) if linear else edge_pixels
# Mean into pre-allocated buffer (no intermediate float64 array) # Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf) np.mean(src, axis=axis, out=edge_1d_buf)
# Cumulative sum so each LED segment's sum is two array lookups apart. # Cumulative sum so each LED segment's sum is two array lookups apart.
cumsum_buf[0] = 0 cumsum_buf[0] = 0
@@ -122,8 +141,16 @@ def average_edge_to_leds(
np.take(cumsum_buf, starts, axis=0, out=starts_buf) np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf) np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf) np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf) if dither:
np.copyto(out_uint8, sums_buf, casting="unsafe") # sums_buf is linear [0,1] or sRGB [0,255]; quantize with dithering.
srgb_f = linear_to_srgb_float(sums_buf) if linear else sums_buf
np.copyto(out_uint8, ordered_dither_quantize(srgb_f, frame_index))
elif linear:
# sums_buf holds linear [0, 1] averages — re-encode to sRGB uint8.
np.copyto(out_uint8, linear_to_srgb_uint8(sums_buf))
else:
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8 return out_uint8
@@ -19,6 +19,17 @@ logger = get_logger(__name__)
ARDUINO_RESET_DELAY = 2.0 # seconds to wait after opening serial for Arduino bootloader ARDUINO_RESET_DELAY = 2.0 # seconds to wait after opening serial for Arduino bootloader
# Settle time between sending the final black frame and closing the port.
# Closing the serial port deasserts DTR, which triggers the Arduino
# auto-reset on most Adalight boards. ``flush()`` only guarantees the bytes
# left the host UART — NOT that the board received the frame, parsed it, and
# ran the LED ``show()`` before the reset wipes its RAM. Without this pause
# the reset intermittently wins the race and the strip latches its last lit
# frame instead of going dark (observed as "the strip sometimes stays on
# after the target/automation stops"). 150 ms is far more than the few ms a
# board needs to paint one frame, and it's a one-shot cost on teardown.
BLACK_FRAME_SETTLE_DELAY = 0.15
def parse_adalight_url(url: str) -> Tuple[str, int]: def parse_adalight_url(url: str) -> Tuple[str, int]:
"""Backwards-compatible alias for :func:`parse_serial_url`.""" """Backwards-compatible alias for :func:`parse_serial_url`."""
@@ -126,6 +137,10 @@ class AdalightClient(LEDClient):
) )
await loop.run_in_executor(executor, self._serial.write, frame) await loop.run_in_executor(executor, self._serial.write, frame)
await loop.run_in_executor(executor, self._serial.flush) await loop.run_in_executor(executor, self._serial.flush)
# Let the board parse the frame and run show() before close()
# toggles DTR and resets it — otherwise the strip can latch its
# last lit frame instead of going dark. See BLACK_FRAME_SETTLE_DELAY.
await asyncio.sleep(BLACK_FRAME_SETTLE_DELAY)
logger.info(f"Adalight black frame sent and flushed: {self._port}") logger.info(f"Adalight black frame sent and flushed: {self._port}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to send black frame on close: {e}") logger.warning(f"Failed to send black frame on close: {e}")
@@ -79,6 +79,9 @@ class HueConfig(BaseDeviceConfig):
hue_username: str = "" hue_username: str = ""
hue_client_key: str = "" hue_client_key: str = ""
hue_entertainment_group_id: str = "" hue_entertainment_group_id: str = ""
# Map the strip across the entertainment configuration's channels
# (gradient-lightstrip segments) instead of one record per light.
hue_gradient_mode: bool = True
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -115,6 +118,8 @@ class LIFXConfig(BaseDeviceConfig):
device_type: Literal["lifx"] = "lifx" device_type: Literal["lifx"] = "lifx"
lifx_min_interval_ms: int = 50 lifx_min_interval_ms: int = 50
# Per-zone/tile streaming (Z/Beam multizone, Tile/Canvas matrix) vs single colour.
lifx_per_zone: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -153,6 +158,8 @@ class NanoleafConfig(BaseDeviceConfig):
device_type: Literal["nanoleaf"] = "nanoleaf" device_type: Literal["nanoleaf"] = "nanoleaf"
nanoleaf_token: str = "" nanoleaf_token: str = ""
nanoleaf_min_interval_ms: int = 100 nanoleaf_min_interval_ms: int = 100
# Per-panel extControl UDP streaming (addresses each panel) vs single colour.
nanoleaf_per_panel: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
+91 -13
View File
@@ -9,6 +9,7 @@ from typing import List, Tuple
import numpy as np import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import resample_to_n
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -24,15 +25,40 @@ COLOR_SPACE_RGB = 0x00
HEADER_SIZE = 16 HEADER_SIZE = 16
def parse_entertainment_channels(config_json: dict) -> List[int]:
"""Extract ordered channel ids from an entertainment_configuration GET.
Entertainment API v2 keys stream records by *channel*, not by light. A
plain bulb contributes one channel; a gradient lightstrip contributes up
to five (one per segment). We order channels left-to-right by their
spatial ``position`` (x then y) so a strip maps across the segments in
physical order. Channels without a position fall back to channel-id order.
"""
data = config_json.get("data") or []
if not data:
return []
channels = data[0].get("channels") or []
def _key(c: dict) -> tuple:
pos = c.get("position") or {}
return (pos.get("x", 0.0), pos.get("y", 0.0), c.get("channel_id", 0))
ordered = sorted(channels, key=_key)
return [int(c["channel_id"]) for c in ordered if "channel_id" in c]
def _build_entertainment_frame( def _build_entertainment_frame(
lights: List[Tuple[int, int, int]], colors: List[Tuple[int, int, int]],
brightness: int = 255, brightness: int = 255,
sequence: int = 0, sequence: int = 0,
channel_ids: List[int] | None = None,
) -> bytes: ) -> bytes:
"""Build a Hue Entertainment API v2 UDP frame. """Build a Hue Entertainment API v2 UDP frame.
Each light gets 7 bytes: [light_id(2B)][R(2B)][G(2B)][B(2B)] Each record is 7 bytes: [channel_id(1B)][R(2B)][G(2B)][B(2B)]. Colors are
Colors are 16-bit (0-65535). We scale 8-bit RGB + brightness. 16-bit (0-65535). Record ``i`` is keyed by ``channel_ids[i]`` when a
channel map is supplied (gradient-segment mode); otherwise it falls back
to the 0-based index (one light = one channel, the legacy behaviour).
""" """
# Header # Header
header = bytearray(HEADER_SIZE) header = bytearray(HEADER_SIZE)
@@ -45,15 +71,15 @@ def _build_entertainment_frame(
header[14] = COLOR_SPACE_RGB header[14] = COLOR_SPACE_RGB
header[15] = 0x00 # reserved header[15] = 0x00 # reserved
# Light data # Channel data
# Note: brightness already applied by processor loop (_cached_brightness) # Note: brightness already applied by processor loop (_cached_brightness)
data = bytearray() data = bytearray()
for idx, (r, g, b) in enumerate(lights): for idx, (r, g, b) in enumerate(colors):
light_id = idx # 0-based light index in entertainment group channel_id = channel_ids[idx] if channel_ids and idx < len(channel_ids) else idx
r16 = int(r * 257) # scale 0-255 to 0-65535 r16 = int(r * 257) # scale 0-255 to 0-65535
g16 = int(g * 257) g16 = int(g * 257)
b16 = int(b * 257) b16 = int(b * 257)
data += struct.pack(">BHHH", light_id, r16, g16, b16) data += struct.pack(">BHHH", channel_id & 0xFF, r16, g16, b16)
return bytes(header) + bytes(data) return bytes(header) + bytes(data)
@@ -62,7 +88,11 @@ class HueClient(LEDClient):
"""LED client for Philips Hue Entertainment API streaming. """LED client for Philips Hue Entertainment API streaming.
Uses UDP (optionally DTLS) to stream color data at ~25 fps to a Hue Uses UDP (optionally DTLS) to stream color data at ~25 fps to a Hue
entertainment group. Each light in the group is treated as one "LED". entertainment group. In ``gradient_mode`` (the default) the client
discovers the entertainment configuration's *channels* on connect and
maps the strip across them, so a gradient lightstrip shows a gradient
instead of a single averaged colour. With gradient mode off (or if
discovery fails) it falls back to one record per light by index.
""" """
def __init__( def __init__(
@@ -72,6 +102,7 @@ class HueClient(LEDClient):
hue_username: str = "", hue_username: str = "",
hue_client_key: str = "", hue_client_key: str = "",
hue_entertainment_group_id: str = "", hue_entertainment_group_id: str = "",
gradient_mode: bool = True,
**kwargs, **kwargs,
): ):
self._bridge_ip = url.replace("hue://", "").rstrip("/") self._bridge_ip = url.replace("hue://", "").rstrip("/")
@@ -79,15 +110,55 @@ class HueClient(LEDClient):
self._username = hue_username self._username = hue_username
self._client_key = hue_client_key self._client_key = hue_client_key
self._group_id = hue_entertainment_group_id self._group_id = hue_entertainment_group_id
self._gradient_mode = gradient_mode
self._channel_ids: List[int] = []
self._sock: socket.socket | None = None self._sock: socket.socket | None = None
self._connected = False self._connected = False
self._sequence = 0 self._sequence = 0
self._dtls_sock = None self._dtls_sock = None
@property
def device_led_count(self) -> int | None:
# In gradient mode the discovered channel count is authoritative.
if self._gradient_mode and self._channel_ids:
return len(self._channel_ids)
return self._led_count or None
async def _fetch_channels(self) -> None:
"""Best-effort discovery of the entertainment configuration channels."""
import httpx
url = (
f"https://{self._bridge_ip}/clip/v2/resource/"
f"entertainment_configuration/{self._group_id}"
)
headers = {"hue-application-key": self._username}
async with httpx.AsyncClient(verify=False, timeout=5.0) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
self._channel_ids = parse_entertainment_channels(resp.json())
if self._channel_ids:
logger.info(
"Hue %s: mapped strip across %d channels", self._bridge_ip, len(self._channel_ids)
)
async def connect(self) -> bool: async def connect(self) -> bool:
# Activate entertainment streaming via REST API # Activate entertainment streaming via REST API
await self._activate_streaming(True) await self._activate_streaming(True)
# Best-effort channel discovery for gradient-segment mapping. On any
# failure (old bridge, network) we degrade to legacy 1-light-1-record.
if self._gradient_mode:
try:
await self._fetch_channels()
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
logger.warning(
"Hue %s: channel discovery failed, using per-light mapping (%s)",
self._bridge_ip,
exc,
)
self._channel_ids = []
# Open UDP socket for entertainment streaming # Open UDP socket for entertainment streaming
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setblocking(False) self._sock.setblocking(False)
@@ -179,12 +250,19 @@ class HueClient(LEDClient):
if not self._connected: if not self._connected:
return return
if isinstance(pixels, np.ndarray): # Resample the strip to the number of addressable elements: the
light_colors = [tuple(pixels[i]) for i in range(min(len(pixels), self._led_count))] # discovered channel count in gradient mode, else the configured
else: # light count. ``resample_to_n`` spreads the strip spatially and
light_colors = pixels[: self._led_count] # handles both the np.ndarray and list-of-tuples inputs.
target = len(self._channel_ids) if self._channel_ids else self._led_count
colors = resample_to_n(pixels, target)
frame = _build_entertainment_frame(light_colors, brightness, self._sequence) frame = _build_entertainment_frame(
colors,
brightness,
self._sequence,
channel_ids=self._channel_ids or None,
)
self._sequence = (self._sequence + 1) & 0xFF self._sequence = (self._sequence + 1) & 0xFF
try: try:
@@ -50,6 +50,7 @@ class HueDeviceProvider(LEDDeviceProvider):
hue_username=config.hue_username, hue_username=config.hue_username,
hue_client_key=config.hue_client_key, hue_client_key=config.hue_client_key,
hue_entertainment_group_id=config.hue_entertainment_group_id, hue_entertainment_group_id=config.hue_entertainment_group_id,
gradient_mode=getattr(config, "hue_gradient_mode", True),
) )
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
+238 -22
View File
@@ -29,6 +29,7 @@ import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import average_color as _average_color from ledgrab.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.core.devices.pixel_reduce import resample_to_n
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -41,6 +42,21 @@ MSG_GET_SERVICE = 2
MSG_STATE_SERVICE = 3 MSG_STATE_SERVICE = 3
MSG_SET_POWER = 21 MSG_SET_POWER = 21
MSG_SET_COLOR = 102 MSG_SET_COLOR = 102
# Multizone (Z / Beam) and matrix (Tile / Canvas) per-pixel messages.
MSG_GET_COLOR_ZONES = 502
MSG_STATE_ZONE = 503
MSG_STATE_MULTIZONE = 506
MSG_SET_EXTENDED_COLOR_ZONES = 510
MSG_GET_DEVICE_CHAIN = 701
MSG_STATE_DEVICE_CHAIN = 702
MSG_SET_TILE_STATE_64 = 715
_EXT_ZONE_MAX = 82 # SetExtendedColorZones carries a fixed 82-slot HSBK array
_TILE_PIXELS = 64 # SetTileState64 always carries 64 HSBK
_TILE_STRUCT_SIZE = 55 # bytes per Tile struct in StateDeviceChain
_TILE_CHAIN_MAX = 16 # StateDeviceChain always lists 16 tile slots
# Apply field for zone writes: 0=NO_APPLY (buffer), 1=APPLY, 2=APPLY_ONLY
_ZONE_APPLY = 1
# Frame field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024 # Frame field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024
_FRAME_TAGGED = 0x3400 _FRAME_TAGGED = 0x3400
@@ -142,6 +158,105 @@ def _build_set_power_payload(on: bool, duration_ms: int = 0) -> bytes:
return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF) return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF)
def _build_get_color_zones_payload(start: int = 0, end: int = 255) -> bytes:
"""GetColorZones (502) payload: start_index(1) | end_index(1)."""
return struct.pack("<BB", start & 0xFF, end & 0xFF)
def _pack_hsbk_array(hsbk_list: List[Tuple[int, int, int, int]], slots: int) -> bytes:
"""Pack a fixed-length HSBK array, zero-padding unused slots.
Both SetExtendedColorZones (82 slots) and SetTileState64 (64 slots)
carry a fixed-size color array regardless of how many are in use.
"""
body = bytearray()
for h, s, b, k in hsbk_list[:slots]:
body += struct.pack("<HHHH", h & 0xFFFF, s & 0xFFFF, b & 0xFFFF, k & 0xFFFF)
used = min(len(hsbk_list), slots)
body += b"\x00" * (8 * (slots - used))
return bytes(body)
def _build_set_extended_color_zones_payload(
hsbk_list: List[Tuple[int, int, int, int]],
*,
duration_ms: int = 0,
apply: int = _ZONE_APPLY,
zone_index: int = 0,
) -> bytes:
"""SetExtendedColorZones (510): duration(4)|apply(1)|zone_index(2)|count(1)|82×HSBK."""
count = min(len(hsbk_list), _EXT_ZONE_MAX)
header = struct.pack(
"<IBHB", duration_ms & 0xFFFFFFFF, apply & 0xFF, zone_index & 0xFFFF, count & 0xFF
)
return header + _pack_hsbk_array(hsbk_list, _EXT_ZONE_MAX)
def _build_set_tile_state64_payload(
hsbk_list: List[Tuple[int, int, int, int]],
*,
tile_index: int = 0,
x: int = 0,
y: int = 0,
width: int = 8,
duration_ms: int = 0,
) -> bytes:
"""SetTileState64 (715): tile_index|length|reserved|x|y|width|duration(4)|64×HSBK."""
header = struct.pack(
"<BBBBBBI",
tile_index & 0xFF,
1, # length: number of tiles this packet addresses
0, # reserved
x & 0xFF,
y & 0xFF,
width & 0xFF,
duration_ms & 0xFFFFFFFF,
)
return header + _pack_hsbk_array(hsbk_list, _TILE_PIXELS)
def _parse_multizone_reply(raw: bytes) -> dict | None:
"""Parse StateZone (503) / StateMultiZone (506) → ``{"count", "index"}``.
``count`` is the device's *total* zone count; ``index`` is where this
packet's run starts. Returns ``None`` for any other message type.
"""
if len(raw) < 36 + 2:
return None
msg_type = struct.unpack_from("<H", raw, 32)[0]
if msg_type not in (MSG_STATE_ZONE, MSG_STATE_MULTIZONE):
return None
count, index = struct.unpack_from("<BB", raw, 36)
return {"count": int(count), "index": int(index)}
def _parse_state_device_chain(raw: bytes) -> dict | None:
"""Parse StateDeviceChain (702) → ``{"start_index", "tiles": [(w, h), ...]}``.
Payload: start_index(1) | 16 × Tile(55 bytes) | tile_devices_count(1).
Within each 55-byte Tile struct, ``width`` is byte 16 and ``height``
byte 17 (after accel int16×4 + user_x/user_y float32×2). Returns ``None``
for any other message type.
"""
min_len = 36 + 1 + _TILE_STRUCT_SIZE * _TILE_CHAIN_MAX + 1
if len(raw) < min_len:
return None
msg_type = struct.unpack_from("<H", raw, 32)[0]
if msg_type != MSG_STATE_DEVICE_CHAIN:
return None
payload_off = 36
start_index = raw[payload_off]
count_off = payload_off + 1 + _TILE_STRUCT_SIZE * _TILE_CHAIN_MAX
total = min(raw[count_off], _TILE_CHAIN_MAX)
tiles: list[tuple[int, int]] = []
for i in range(total):
base = payload_off + 1 + i * _TILE_STRUCT_SIZE
width = raw[base + 16]
height = raw[base + 17]
tiles.append((int(width), int(height)))
return {"start_index": int(start_index), "tiles": tiles}
def _parse_state_service_reply(raw: bytes) -> dict | None: def _parse_state_service_reply(raw: bytes) -> dict | None:
"""Parse a LIFX StateService (discovery) reply. """Parse a LIFX StateService (discovery) reply.
@@ -163,15 +278,25 @@ def _parse_state_service_reply(raw: bytes) -> dict | None:
class _LIFXProtocol(asyncio.DatagramProtocol): class _LIFXProtocol(asyncio.DatagramProtocol):
"""Write-only datagram protocol. Inbound replies dropped silently.""" """Datagram protocol that buffers inbound replies for zone/tile discovery.
The streaming hot path never reads replies, but per-zone setup needs the
StateMultiZone / StateDeviceChain answers. The buffer is bounded so a
chatty bulb can't grow it without limit during steady-state streaming.
"""
_MAX_BUFFER = 64
def __init__(self) -> None:
self.transport: asyncio.DatagramTransport | None = None
self.received: list[bytes] = []
def connection_made(self, transport): def connection_made(self, transport):
self.transport = transport self.transport = transport
def datagram_received(self, data, addr): def datagram_received(self, data, addr):
# LIFX bulbs sometimes echo back state on broadcast. We don't need it if len(self.received) < self._MAX_BUFFER:
# for streaming ambilight — discard. self.received.append(bytes(data))
pass
def error_received(self, exc): def error_received(self, exc):
logger.debug("LIFX UDP error: %s", exc) logger.debug("LIFX UDP error: %s", exc)
@@ -186,6 +311,7 @@ class LIFXClient(LEDClient):
led_count: int = 1, led_count: int = 1,
*, *,
min_interval_s: float = DEFAULT_MIN_INTERVAL_S, min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
per_zone: bool = False,
): ):
host, port = parse_lifx_url(url) host, port = parse_lifx_url(url)
self._host = host self._host = host
@@ -197,6 +323,12 @@ class LIFXClient(LEDClient):
self._connected = False self._connected = False
self._next_tx_at: float = 0.0 self._next_tx_at: float = 0.0
self._sequence: int = 0 self._sequence: int = 0
# Per-pixel state. ``_mode`` is "single" until connect() probes the
# device and finds multizone (Z/Beam) or tile (matrix) support.
self._per_zone = per_zone
self._mode = "single" # "single" | "multizone" | "tile"
self._zone_count = 0
self._tiles: list[tuple[int, int]] = []
@property @property
def host(self) -> str: def host(self) -> str:
@@ -212,6 +344,12 @@ class LIFXClient(LEDClient):
@property @property
def device_led_count(self) -> int | None: def device_led_count(self) -> int | None:
# In per-zone streaming the device's addressable element count is
# authoritative (zones for multizone, total pixels for tiles).
if self._mode == "multizone" and self._zone_count:
return self._zone_count
if self._mode == "tile" and self._tiles:
return sum(w * h for w, h in self._tiles)
return self._led_count or None return self._led_count or None
async def connect(self) -> bool: async def connect(self) -> bool:
@@ -227,9 +365,56 @@ class LIFXClient(LEDClient):
self._transport = transport self._transport = transport
self._protocol = protocol # type: ignore[assignment] self._protocol = protocol # type: ignore[assignment]
self._connected = True self._connected = True
logger.info("LIFXClient connected to %s:%d", self._host, self._port) if self._per_zone:
# Best-effort: probe for multizone / tile support. On timeout or
# an old single-colour bulb we silently stay in single-colour mode.
try:
await self._setup_zones()
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
logger.warning(
"LIFX %s: per-zone setup failed, using single colour (%s)", self._host, exc
)
self._mode = "single"
logger.info(
"LIFXClient connected to %s:%d (per_zone=%s, mode=%s)",
self._host,
self._port,
self._per_zone,
self._mode,
)
return True return True
async def _setup_zones(self) -> None:
"""Probe the device for multizone / tile support and pick a mode.
Sends GetColorZones + GetDeviceChain and waits briefly for whichever
reply the device returns (a strip answers StateMultiZone; a tile chain
answers StateDeviceChain). Leaves ``_mode`` at "single" if neither.
"""
if self._protocol is None:
return
self._protocol.received.clear()
self._send(MSG_GET_DEVICE_CHAIN, b"")
self._send(MSG_GET_COLOR_ZONES, _build_get_color_zones_payload())
loop = asyncio.get_event_loop()
deadline = loop.time() + 0.6
while loop.time() < deadline:
await asyncio.sleep(0.05)
for raw in list(self._protocol.received):
chain = _parse_state_device_chain(raw)
if chain and chain["tiles"]:
self._tiles = chain["tiles"]
self._mode = "tile"
logger.info("LIFX %s: tile chain (%d tiles)", self._host, len(self._tiles))
return
zones = _parse_multizone_reply(raw)
if zones and zones["count"] > 1:
self._zone_count = min(zones["count"], _EXT_ZONE_MAX)
self._mode = "multizone"
logger.info("LIFX %s: multizone (%d zones)", self._host, self._zone_count)
return
self._mode = "single"
async def close(self) -> None: async def close(self) -> None:
if self._transport is not None: if self._transport is not None:
try: try:
@@ -255,25 +440,63 @@ class LIFXClient(LEDClient):
) )
self._transport.sendto(packet) self._transport.sendto(packet)
def _hsbk_list(
self,
pixels: List[Tuple[int, int, int]] | np.ndarray,
n: int,
scale: float,
) -> List[Tuple[int, int, int, int]]:
"""Resample the strip to ``n`` pixels and convert each to HSBK."""
out: List[Tuple[int, int, int, int]] = []
for r, g, b in resample_to_n(pixels, n):
if scale != 1.0:
r, g, b = int(r * scale), int(g * scale), int(b * scale)
out.append(rgb_to_hsbk(r, g, b))
return out
def _emit_pixels(
self,
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int,
) -> None:
"""Build and send the packet(s) for the current mode (single/zone/tile)."""
scale = max(0, min(255, brightness)) / 255.0 if brightness < 255 else 1.0
if self._mode == "multizone" and self._zone_count > 0:
hsbk = self._hsbk_list(pixels, self._zone_count, scale)
self._send(MSG_SET_EXTENDED_COLOR_ZONES, _build_set_extended_color_zones_payload(hsbk))
return
if self._mode == "tile" and self._tiles:
total = sum(w * h for w, h in self._tiles)
full = self._hsbk_list(pixels, total, scale)
offset = 0
for ti, (w, h) in enumerate(self._tiles):
n = w * h
chunk = full[offset : offset + n]
offset += n
self._send(
MSG_SET_TILE_STATE_64,
_build_set_tile_state64_payload(chunk, tile_index=ti, width=w),
)
return
# Single-colour fallback (every non-multizone/tile bulb).
r, g, b = _average_color(pixels)
if scale != 1.0:
r, g, b = int(r * scale), int(g * scale), int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
async def send_pixels( async def send_pixels(
self, self,
pixels: List[Tuple[int, int, int]] | np.ndarray, pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Average the strip → HSBK → SetColor.""" """Stream per-zone/tile when detected, else average the strip → SetColor."""
if not self.is_connected: if not self.is_connected:
raise RuntimeError("LIFXClient not connected") raise RuntimeError("LIFXClient not connected")
now = time.monotonic() now = time.monotonic()
if now < self._next_tx_at: if now < self._next_tx_at:
return True return True
r, g, b = _average_color(pixels) self._emit_pixels(pixels, brightness)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
self._next_tx_at = now + self._min_interval_s self._next_tx_at = now + self._min_interval_s
return True return True
@@ -288,14 +511,7 @@ class LIFXClient(LEDClient):
now = time.monotonic() now = time.monotonic()
if now < self._next_tx_at: if now < self._next_tx_at:
return return
r, g, b = _average_color(pixels) self._emit_pixels(pixels, brightness)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
self._next_tx_at = now + self._min_interval_s self._next_tx_at = now + self._min_interval_s
@property @property
@@ -51,6 +51,7 @@ class LIFXDeviceProvider(LEDDeviceProvider):
config.device_url, config.device_url,
led_count=config.led_count, led_count=config.led_count,
min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0), min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0),
per_zone=getattr(config, "lifx_per_zone", False),
) )
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
@@ -6,12 +6,13 @@ handshake: the user holds the controller's power button for 5 seconds to
open a 30-second pairing window, then we POST to ``/api/v1/new`` to claim open a 30-second pairing window, then we POST to ``/api/v1/new`` to claim
an auth token. The token is long-lived and gets stored on the device. an auth token. The token is long-lived and gets stored on the device.
Once paired, color control is a simple ``PUT /api/v1/{token}/state`` with Two output modes:
HSBT (hue / saturation / brightness; kelvin only matters when sat=0). * **Single-colour** (default): average the strip to one HSB triple and
LedGrab averages the incoming strip to one HSB triple. Per-panel streaming ``PUT /api/v1/{token}/state`` matches every other consumer-bulb driver.
mode (``extControl`` UDP, ~60 Hz, addresses each panel individually) is * **Per-panel** (``per_panel=True``): enable ``extControl`` v2 and stream a
documented but not implemented here the MVP keeps the device acting as UDP packet per frame to port 60222, addressing each panel individually
a single-pixel target like Yeelight / Hue. (resampled from the strip). Enabled when the device config opts in; falls
back to single-colour if the controller/firmware can't stream.
URL scheme: ``nanoleaf://<host>``. Port is fixed at 16021 on the protocol URL scheme: ``nanoleaf://<host>``. Port is fixed at 16021 on the protocol
side. The auth token is stored separately on the device config, not in side. The auth token is stored separately on the device config, not in
@@ -23,6 +24,8 @@ Reference: https://forum.nanoleaf.me/docs/openapi
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import socket
import struct
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Tuple from typing import List, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -37,9 +40,62 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
NANOLEAF_PORT = 16021 NANOLEAF_PORT = 16021
# extControl v2 UDP streaming target port (fixed by the protocol).
NANOLEAF_STREAM_PORT = 60222
DEFAULT_MIN_INTERVAL_S = 0.1 # 10 Hz; HTTP per frame, plenty for averaged ambilight DEFAULT_MIN_INTERVAL_S = 0.1 # 10 Hz; HTTP per frame, plenty for averaged ambilight
def order_panels(position_data: List[dict]) -> List[int]:
"""Order panel IDs left-to-right, top-to-bottom from ``panelLayout`` data.
Each ``positionData`` entry has ``panelId``/``x``/``y``. We sort by (x, y)
so an ambient strip maps across the panels spatially. Entries without an
integer ``panelId`` (or the controller/rhythm panel, panelId 0) are dropped.
"""
panels = [
p for p in position_data if isinstance(p.get("panelId"), int) and p.get("panelId", 0) != 0
]
panels.sort(key=lambda p: (p.get("x", 0), p.get("y", 0)))
return [int(p["panelId"]) for p in panels]
def map_pixels_to_panels(
pixels: List[Tuple[int, int, int]] | np.ndarray,
panel_ids: List[int],
) -> List[Tuple[int, int, int, int]]:
"""Resample an N-pixel strip to one ``(panel_id, r, g, b)`` per panel.
Nearest-neighbour resample: panel ``i`` of ``M`` samples strip pixel
``floor(i * N / M)``. Returns black for an empty strip.
"""
arr = np.asarray(pixels, dtype=np.uint8).reshape(-1, 3)
n_pix = len(arr)
n_panels = max(1, len(panel_ids))
out: List[Tuple[int, int, int, int]] = []
for i, pid in enumerate(panel_ids):
if n_pix == 0:
out.append((pid, 0, 0, 0))
continue
idx = min(n_pix - 1, (i * n_pix) // n_panels)
px = arr[idx]
out.append((pid, int(px[0]), int(px[1]), int(px[2])))
return out
def build_extcontrol_v2_packet(panels: List[Tuple[int, int, int, int]]) -> bytes:
"""Build a Nanoleaf extControl **v2** UDP streaming packet.
Wire format (all multi-byte fields big-endian):
``uint16 nPanels`` then per panel
``uint16 panelId, uint8 R, uint8 G, uint8 B, uint8 W, uint16 transitionTime``
``transitionTime`` is in 100 ms units; 1 = a 100 ms ease for smooth motion.
"""
parts = [struct.pack(">H", len(panels))]
for pid, r, g, b in panels:
parts.append(struct.pack(">HBBBBH", pid & 0xFFFF, r & 0xFF, g & 0xFF, b & 0xFF, 0, 1))
return b"".join(parts)
def parse_nanoleaf_url(url: str) -> str: def parse_nanoleaf_url(url: str) -> str:
"""Pull the host out of ``nanoleaf://host`` or accept a bare host. """Pull the host out of ``nanoleaf://host`` or accept a bare host.
@@ -131,6 +187,7 @@ class NanoleafClient(LEDClient):
auth_token: str = "", auth_token: str = "",
min_interval_s: float = DEFAULT_MIN_INTERVAL_S, min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
request_timeout_s: float = 3.0, request_timeout_s: float = 3.0,
per_panel: bool = False,
): ):
self._host = parse_nanoleaf_url(url) self._host = parse_nanoleaf_url(url)
self._token = auth_token self._token = auth_token
@@ -140,6 +197,11 @@ class NanoleafClient(LEDClient):
self._http: httpx.AsyncClient | None = None self._http: httpx.AsyncClient | None = None
self._connected = False self._connected = False
self._next_tx_at: float = 0.0 self._next_tx_at: float = 0.0
# Per-panel extControl streaming state.
self._per_panel = per_panel
self._streaming = False
self._panel_ids: List[int] = []
self._udp: socket.socket | None = None
@property @property
def host(self) -> str: def host(self) -> str:
@@ -157,6 +219,9 @@ class NanoleafClient(LEDClient):
@property @property
def device_led_count(self) -> int | None: def device_led_count(self) -> int | None:
# In per-panel streaming mode the panel count is authoritative.
if self._streaming and self._panel_ids:
return len(self._panel_ids)
return self._led_count or None return self._led_count or None
def _state_url(self) -> str: def _state_url(self) -> str:
@@ -169,9 +234,63 @@ class NanoleafClient(LEDClient):
raise RuntimeError("NanoleafClient requires an auth_token; pair the device first") raise RuntimeError("NanoleafClient requires an auth_token; pair the device first")
self._http = httpx.AsyncClient(timeout=self._request_timeout_s) self._http = httpx.AsyncClient(timeout=self._request_timeout_s)
self._connected = True self._connected = True
logger.info("NanoleafClient connected to %s:%d", self._host, NANOLEAF_PORT) if self._per_panel:
# Best-effort: if streaming can't be enabled (old firmware, network)
# we silently fall back to the single-colour HTTP path.
try:
await self._setup_streaming()
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
logger.warning(
"Nanoleaf %s: per-panel streaming unavailable, using single-colour (%s)",
self._host,
exc,
)
self._streaming = False
logger.info(
"NanoleafClient connected to %s:%d (per_panel=%s, streaming=%s)",
self._host,
NANOLEAF_PORT,
self._per_panel,
self._streaming,
)
return True return True
async def _setup_streaming(self) -> None:
"""Fetch the panel layout and enable extControl v2 UDP streaming."""
if self._http is None:
raise RuntimeError("NanoleafClient not connected")
base = f"http://{self._host}:{NANOLEAF_PORT}/api/v1/{self._token}"
# 1) Panel layout → ordered panel IDs.
resp = await self._http.get(f"{base}/panelLayout/layout")
resp.raise_for_status()
position_data = resp.json().get("positionData", [])
panel_ids = order_panels(position_data)
if not panel_ids:
raise RuntimeError("Nanoleaf reported no addressable panels")
# 2) Switch the controller into external (UDP) control, v2.
enable = await self._http.put(
f"{base}/effects",
json={
"write": {
"command": "display",
"animType": "extControl",
"extControlVersion": "v2",
}
},
)
if enable.status_code not in (200, 204):
raise RuntimeError(
f"Nanoleaf refused extControl enable ({enable.status_code}): {enable.text[:200]}"
)
# 3) Fire-and-forget UDP socket for the per-frame stream.
self._udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._udp.setblocking(False)
self._panel_ids = panel_ids
self._streaming = True
logger.info(
"Nanoleaf %s: extControl v2 streaming over %d panels", self._host, len(panel_ids)
)
async def close(self) -> None: async def close(self) -> None:
if self._http is not None: if self._http is not None:
try: try:
@@ -179,6 +298,13 @@ class NanoleafClient(LEDClient):
except (httpx.HTTPError, RuntimeError): except (httpx.HTTPError, RuntimeError):
pass pass
self._http = None self._http = None
if self._udp is not None:
try:
self._udp.close()
except OSError:
pass
self._udp = None
self._streaming = False
self._connected = False self._connected = False
async def _put_state(self, body: dict) -> None: async def _put_state(self, body: dict) -> None:
@@ -197,12 +323,28 @@ class NanoleafClient(LEDClient):
pixels: List[Tuple[int, int, int]] | np.ndarray, pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Average the strip and PUT a single HSB state update.""" """Stream per-panel (extControl) when enabled, else average to one HSB state."""
if not self.is_connected: if not self.is_connected:
raise RuntimeError("NanoleafClient not connected") raise RuntimeError("NanoleafClient not connected")
loop_now = asyncio.get_event_loop().time() loop_now = asyncio.get_event_loop().time()
if loop_now < self._next_tx_at: if loop_now < self._next_tx_at:
return True return True
if self._streaming and self._udp is not None and self._panel_ids:
scale = max(0, min(255, brightness)) / 255.0 if brightness < 255 else 1.0
arr = np.asarray(pixels, dtype=np.float32).reshape(-1, 3)
if scale != 1.0:
arr = arr * scale
scaled = np.clip(arr, 0, 255).astype(np.uint8)
panels = map_pixels_to_panels(scaled, self._panel_ids)
packet = build_extcontrol_v2_packet(panels)
try:
self._udp.sendto(packet, (self._host, NANOLEAF_STREAM_PORT))
except OSError as exc:
logger.debug("Nanoleaf %s: UDP stream send failed (%s)", self._host, exc)
self._next_tx_at = loop_now + self._min_interval_s
return True
r, g, b = _average_color(pixels) r, g, b = _average_color(pixels)
if brightness < 255: if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0 scale = max(0, min(255, brightness)) / 255.0
@@ -61,6 +61,7 @@ class NanoleafDeviceProvider(LEDDeviceProvider):
led_count=config.led_count, led_count=config.led_count,
auth_token=config.nanoleaf_token, auth_token=config.nanoleaf_token,
min_interval_s=max(0.0, config.nanoleaf_min_interval_ms / 1000.0), min_interval_s=max(0.0, config.nanoleaf_min_interval_ms / 1000.0),
per_panel=getattr(config, "nanoleaf_per_panel", False),
) )
async def pair_device(self, url: str) -> dict: async def pair_device(self, url: str) -> dict:
@@ -40,3 +40,29 @@ def average_color(
total_b += b total_b += b
n = len(pixels) n = len(pixels)
return total_r // n, total_g // n, total_b // n return total_r // n, total_g // n, total_b // n
def resample_to_n(
pixels: List[Tuple[int, int, int]] | np.ndarray,
n: int,
) -> List[Tuple[int, int, int]]:
"""Nearest-neighbour resample an N-pixel strip to exactly ``n`` pixels.
Output pixel ``i`` of ``n`` samples input pixel ``floor(i * N / n)``, so
the strip spreads spatially across a multi-element device (LIFX zones/
tiles, Nanoleaf panels, Hue segments). Black is returned for an empty
strip; ``n <= 0`` yields an empty list.
"""
if n <= 0:
return []
arr = np.asarray(pixels, dtype=np.uint8).reshape(-1, 3)
n_pix = len(arr)
out: List[Tuple[int, int, int]] = []
for i in range(n):
if n_pix == 0:
out.append((0, 0, 0))
continue
idx = min(n_pix - 1, (i * n_pix) // n)
px = arr[idx]
out.append((int(px[0]), int(px[1]), int(px[2])))
return out
@@ -262,8 +262,10 @@ class CS2Adapter(GameAdapter):
actual_token = auth_section.get("token", "") actual_token = auth_section.get("token", "")
if not actual_token: if not actual_token:
return False return False
# Constant-time comparison to avoid a timing oracle. # Constant-time comparison to avoid a timing oracle. Compare UTF-8 bytes
return secrets.compare_digest(actual_token, expected_token) # so an attacker-controlled non-ASCII payload token returns False rather
# than raising TypeError out of secrets.compare_digest (→ 500).
return secrets.compare_digest(actual_token.encode("utf-8"), expected_token.encode("utf-8"))
@classmethod @classmethod
def get_config_schema(cls) -> dict[str, Any]: def get_config_schema(cls) -> dict[str, Any]:
@@ -179,8 +179,10 @@ class Dota2Adapter(GameAdapter):
actual_token = auth_section.get("token", "") actual_token = auth_section.get("token", "")
if not actual_token: if not actual_token:
return False return False
# Constant-time comparison to avoid a timing oracle. # Constant-time comparison to avoid a timing oracle. Compare UTF-8 bytes
return secrets.compare_digest(actual_token, expected_token) # so an attacker-controlled non-ASCII payload token returns False rather
# than raising TypeError out of secrets.compare_digest (→ 500).
return secrets.compare_digest(actual_token.encode("utf-8"), expected_token.encode("utf-8"))
@classmethod @classmethod
def get_config_schema(cls) -> dict[str, Any]: def get_config_schema(cls) -> dict[str, Any]:
@@ -79,7 +79,12 @@ class GenericWebhookAdapter(GameAdapter):
return False return False
# Constant-time comparison to avoid a token-length/timing oracle. # 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 @classmethod
def get_config_schema(cls) -> dict[str, Any]: def get_config_schema(cls) -> dict[str, Any]:
@@ -0,0 +1,90 @@
"""Runtime manager that owns LoL Live-Client-Data polling threads.
Unlike GSI adapters (CS2, Dota 2) that push events into the HTTP ingest
endpoint, League of Legends only exposes a *local* poll API. The
:class:`~ledgrab.core.game_integration.adapters.lol_adapter.LoLPoller` knows how
to poll it; this manager owns the poller lifecycle, starting one daemon poller
per enabled ``lol`` integration and reconciling on config changes.
``sync()`` is the single reconciliation entry point call it at startup and
after any integration create/update/delete so runtime pollers always match the
enabled ``lol`` integrations in the store.
"""
from __future__ import annotations
import threading
from typing import Any, Iterable
from ledgrab.core.game_integration.adapters.lol_adapter import LoLAdapter, LoLPoller
from ledgrab.core.game_integration.runtime_state import process_payload
from ledgrab.utils import get_logger
logger = get_logger(__name__)
class LoLPollManager:
"""Owns one :class:`LoLPoller` per enabled League of Legends integration."""
def __init__(self, event_bus: Any) -> None:
self._event_bus = event_bus
self._lock = threading.Lock()
self._pollers: dict[str, LoLPoller] = {}
# Last adapter_config a poller was started with, so a config edit
# (e.g. poll interval) triggers a restart rather than going unnoticed.
self._configs: dict[str, dict[str, Any]] = {}
def sync(self, integrations: Iterable[Any]) -> None:
"""Reconcile running pollers against the enabled ``lol`` integrations."""
desired = {
c.id: c
for c in integrations
if getattr(c, "enabled", False)
and getattr(c, "adapter_type", None) == LoLAdapter.ADAPTER_TYPE
}
with self._lock:
# Stop pollers whose integration is gone, disabled, or reconfigured.
for integration_id in list(self._pollers):
cfg = desired.get(integration_id)
if cfg is None or self._configs.get(integration_id) != dict(cfg.adapter_config):
self._stop_locked(integration_id)
# Start pollers for anything enabled that isn't already running.
for integration_id, cfg in desired.items():
if integration_id not in self._pollers:
self._start_locked(integration_id, cfg)
def stop_all(self) -> None:
"""Stop every poller (shutdown hook)."""
with self._lock:
for integration_id in list(self._pollers):
self._stop_locked(integration_id)
@property
def active_count(self) -> int:
with self._lock:
return len(self._pollers)
# ── internals (call under self._lock) ──────────────────────────────────
def _start_locked(self, integration_id: str, cfg: Any) -> None:
adapter_config = dict(cfg.adapter_config)
poller = LoLPoller(adapter_config, self._make_callback(integration_id, adapter_config))
poller.start()
self._pollers[integration_id] = poller
self._configs[integration_id] = adapter_config
logger.info("Started LoL poller for integration %s", integration_id)
def _stop_locked(self, integration_id: str) -> None:
poller = self._pollers.pop(integration_id, None)
self._configs.pop(integration_id, None)
if poller is not None:
poller.stop()
logger.info("Stopped LoL poller for integration %s", integration_id)
def _make_callback(self, integration_id: str, adapter_config: dict[str, Any]):
event_bus = self._event_bus
def _on_poll(data: dict[str, Any]) -> None:
process_payload(integration_id, LoLAdapter, adapter_config, data, event_bus)
return _on_poll
@@ -244,8 +244,12 @@ class MappingAdapter(GameAdapter):
actual_value = headers.get(header_name, "") actual_value = headers.get(header_name, "")
if not (expected_value and actual_value): if not (expected_value and actual_value):
return False return False
# Constant-time comparison to avoid a timing oracle. # Constant-time comparison to avoid a timing oracle. Compare UTF-8
return secrets.compare_digest(actual_value, expected_value) # bytes so an attacker-controlled non-ASCII header value returns
# False rather than raising TypeError out of compare_digest (→ 500).
return secrets.compare_digest(
actual_value.encode("utf-8"), expected_value.encode("utf-8")
)
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'") logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
return False return False
@@ -0,0 +1,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
@@ -0,0 +1,141 @@
"""Home Assistant MQTT auto-discovery publisher.
When an :class:`~ledgrab.storage.mqtt_source.MQTTSource` has
``publish_ha_discovery`` enabled, this publishes retained
``<discovery_prefix>/<component>/<node_id>/<object_id>/config`` topics so an
MQTT-only Home Assistant install gets LedGrab entities automatically no YAML.
Scope (read-only telemetry): a ``binary_sensor`` per automation (active /
inactive) plus a connectivity ``binary_sensor`` for LedGrab itself, all tied to
the broker's birth/will ``<base_topic>/status`` availability topic. This is
deliberately one-way (LedGrab HA): there is no inbound command surface, so it
adds no new attack surface. Controllable ``light``/``switch`` entities (HA
LedGrab) are a documented follow-up.
Cleanup: disabling discovery or deleting the source publishes an empty retained
payload to every previously-published config topic, so HA drops the entities
rather than leaving orphans behind forever.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.mqtt.mqtt_runtime import MQTTRuntime
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.mqtt_source import MQTTSource
logger = get_logger(__name__)
def _node_id(source_id: str) -> str:
"""Namespace every entity by source so multiple brokers don't collide."""
return f"ledgrab_{source_id}"
class HADiscoveryPublisher:
"""Builds and publishes HA discovery configs for one MQTT runtime."""
def __init__(
self,
runtime: "MQTTRuntime",
source: "MQTTSource",
automation_store: "AutomationStore",
version: str = "",
) -> None:
self._runtime = runtime
self._source = source
self._automation_store = automation_store
self._version = version
self._base = source.base_topic
self._prefix = source.discovery_prefix or "homeassistant"
# Config topics we have published, so remove_all() can clear exactly them.
self._published: set[str] = set()
# ── Config builders (pure — unit tested) ──────────────────────
def _device_block(self) -> dict:
return {
"identifiers": [_node_id(self._source.id)],
"name": "LedGrab",
"manufacturer": "LedGrab",
"model": "Ambient",
"sw_version": self._version or "unknown",
}
def _config_topic(self, component: str, object_id: str) -> str:
return f"{self._prefix}/{component}/{_node_id(self._source.id)}/{object_id}/config"
def build_connectivity_config(self) -> tuple[str, dict]:
"""A connectivity ``binary_sensor`` reflecting LedGrab's birth/will."""
sid = self._source.id
topic = self._config_topic("binary_sensor", "connectivity")
payload = {
"unique_id": f"ledgrab_{sid}_connectivity",
"name": "LedGrab",
"device_class": "connectivity",
"state_topic": f"{self._base}/status",
"payload_on": "online",
"payload_off": "offline",
"device": self._device_block(),
}
return topic, payload
def build_automation_config(self, automation) -> tuple[str, dict]:
"""A ``binary_sensor`` reflecting an automation's active state."""
sid = self._source.id
topic = self._config_topic("binary_sensor", f"automation_{automation.id}")
payload = {
"unique_id": f"ledgrab_{sid}_automation_{automation.id}",
"name": automation.name,
"state_topic": f"{self._base}/automation/{automation.id}/state",
"value_template": "{{ value_json.action }}",
"payload_on": "active",
"payload_off": "inactive",
"availability_topic": f"{self._base}/status",
"payload_available": "online",
"payload_not_available": "offline",
"device": self._device_block(),
}
return topic, payload
# ── Publish / remove ──────────────────────────────────────────
async def publish_all(self) -> None:
"""Publish (retained) every discovery config + an initial state snapshot."""
topic, payload = self.build_connectivity_config()
await self._runtime.publish(topic, json.dumps(payload), retain=True)
self._published.add(topic)
count = 0
for automation in self._automation_store.get_all():
topic, payload = self.build_automation_config(automation)
await self._runtime.publish(topic, json.dumps(payload), retain=True)
self._published.add(topic)
# Seed an initial "inactive" state; the engine flips it live on the
# next activate/deactivate transition.
await self._runtime.publish_automation_state(automation.id, "inactive")
count += 1
logger.info(
"HA discovery published for source %s: %d automation sensor(s) + connectivity",
self._source.id,
count,
)
async def remove_all(self) -> None:
"""Clear every previously-published config (empty retained payload)."""
for topic in list(self._published):
await self._runtime.publish(topic, "", retain=True)
# Also clear automations that may have been published in a prior run but
# since deleted — recompute the current set and clear those too.
topic, _ = self.build_connectivity_config()
await self._runtime.publish(topic, "", retain=True)
for automation in self._automation_store.get_all():
topic, _ = self.build_automation_config(automation)
await self._runtime.publish(topic, "", retain=True)
self._published.clear()
logger.info("HA discovery removed for source %s", self._source.id)
+91 -1
View File
@@ -22,10 +22,20 @@ class MQTTManager:
Multiple consumers share the same runtime via acquire/release. Multiple consumers share the same runtime via acquire/release.
""" """
def __init__(self, store: MQTTSourceStore) -> None: def __init__(
self,
store: MQTTSourceStore,
automation_store=None,
version: str = "",
) -> None:
self._store = store self._store = store
# Optional deps for HA discovery publishing (injected from main.py).
self._automation_store = automation_store
self._version = version
# source_id -> (runtime, ref_count) # source_id -> (runtime, ref_count)
self._runtimes: Dict[str, tuple] = {} self._runtimes: Dict[str, tuple] = {}
# Sources for which we hold a discovery acquire() reference.
self._discovery_sources: set[str] = set()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
async def acquire(self, source_id: str) -> MQTTRuntime: async def acquire(self, source_id: str) -> MQTTRuntime:
@@ -100,6 +110,86 @@ class MQTTManager:
except Exception as e: except Exception as e:
logger.warning("Failed to update MQTT runtime %s: %s", source_id, e) logger.warning("Failed to update MQTT runtime %s: %s", source_id, e)
# ===== Home Assistant MQTT discovery =====
def _make_publisher(self, runtime: MQTTRuntime, source):
from ledgrab.core.mqtt.ha_discovery import HADiscoveryPublisher
return HADiscoveryPublisher(runtime, source, self._automation_store, version=self._version)
async def bootstrap_discovery(self) -> None:
"""On startup, ensure a runtime + publish discovery for every enabled source."""
if self._automation_store is None:
return
for source in self._store.get_all():
if getattr(source, "publish_ha_discovery", False):
try:
await self.ensure_discovery(source.id)
except Exception as exc: # noqa: BLE001 — best-effort bootstrap
logger.warning("HA discovery bootstrap failed for %s: %s", source.id, exc)
async def ensure_discovery(self, source_id: str) -> None:
"""Hold a runtime open for *source_id* and publish its discovery configs.
Idempotent: a second call re-publishes (configs are retained) without
leaking a second acquire reference.
"""
if self._automation_store is None:
return
try:
source = self._store.get(source_id)
except Exception:
return
if not getattr(source, "publish_ha_discovery", False):
return
first_time = source_id not in self._discovery_sources
if first_time:
runtime = await self.acquire(source_id)
self._discovery_sources.add(source_id)
else:
runtime = self.get_runtime(source_id)
if runtime is None:
return
await self._make_publisher(runtime, source).publish_all()
async def disable_discovery(self, source_id: str) -> None:
"""Clear a source's discovery configs and drop our runtime reference."""
if source_id not in self._discovery_sources:
return
runtime = self.get_runtime(source_id)
if runtime is not None:
try:
source = self._store.get(source_id)
await self._make_publisher(runtime, source).remove_all()
except Exception as exc: # noqa: BLE001 — best-effort cleanup
logger.warning("HA discovery cleanup failed for %s: %s", source_id, exc)
self._discovery_sources.discard(source_id)
await self.release(source_id)
async def sync_discovery(self, source_id: str) -> None:
"""Reconcile discovery state after a source is created/updated."""
try:
source = self._store.get(source_id)
except Exception:
return
if getattr(source, "publish_ha_discovery", False):
await self.ensure_discovery(source_id)
else:
await self.disable_discovery(source_id)
async def publish_automation_state_all(self, automation_id: str, active: bool) -> None:
"""Fan an automation's active state out to every discovery-enabled runtime."""
if not self._discovery_sources:
return
action = "active" if active else "inactive"
for source_id in list(self._discovery_sources):
runtime = self.get_runtime(source_id)
if runtime is not None:
try:
await runtime.publish_automation_state(automation_id, action)
except Exception: # noqa: BLE001 — never raise into the engine
pass
def get_connection_status(self) -> List[Dict[str, Any]]: def get_connection_status(self) -> List[Dict[str, Any]]:
"""Get status of all active MQTT connections (for dashboard indicators).""" """Get status of all active MQTT connections (for dashboard indicators)."""
result = [] result = []
@@ -0,0 +1,115 @@
"""AudioEnergyTap — a lightweight read-only tap on a shared audio capture.
Lets a non-audio stream (e.g. a procedural effect) react to live loudness
without owning audio capture. It resolves an ``audio_source_id`` to capture
params exactly like ``AudioColorStripStream`` does, acquires the SHARED stream
via the ``AudioCaptureManager`` (ref-counted, so it piggybacks on any audio
visualiser already capturing the same device), and exposes a smoothed 01
energy scalar.
"""
from __future__ import annotations
from typing import Any
from ledgrab.utils import get_logger
logger = get_logger(__name__)
# RMS of typical program audio sits around 0.050.3; scale so a moderately
# loud signal reaches ~1.0 before the per-effect intensity slider is applied.
_RMS_GAIN = 4.0
class AudioEnergyTap:
"""Resolve → acquire → read smoothed loudness from a shared audio capture."""
def __init__(
self,
audio_capture_manager: Any,
audio_source_store: Any = None,
audio_template_store: Any = None,
) -> None:
self._mgr = audio_capture_manager
self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store
self._stream = None
self._source_id = ""
self._device_index = -1
self._loopback = True
self._engine_type = None
self._engine_config = None
self._smoothed = 0.0
@property
def available(self) -> bool:
return self._mgr is not None
@property
def active(self) -> bool:
return self._stream is not None
def configure(self, audio_source_id: str) -> None:
"""Resolve capture params for ``audio_source_id`` (no acquire yet)."""
self._source_id = audio_source_id or ""
self._device_index = -1
self._loopback = True
self._engine_type = None
self._engine_config = None
if not self._source_id or not self._audio_source_store:
return
try:
resolved = self._audio_source_store.resolve_audio_source(self._source_id)
self._device_index = resolved.device_index
self._loopback = resolved.is_loopback
if resolved.audio_template_id and self._audio_template_store:
try:
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
self._engine_type = tpl.engine_type
self._engine_config = tpl.engine_config
except ValueError as e:
logger.warning(
"AudioEnergyTap: template %s missing: %s", resolved.audio_template_id, e
)
except ValueError as e:
logger.warning(
"AudioEnergyTap: failed to resolve audio source %s: %s", self._source_id, e
)
def start(self) -> None:
if self._mgr is None or self._stream is not None:
return
try:
self._stream = self._mgr.acquire(
self._device_index,
self._loopback,
engine_type=self._engine_type,
engine_config=self._engine_config,
)
except Exception as e: # acquisition is best-effort — never break the effect
logger.warning("AudioEnergyTap: failed to acquire audio capture: %s", e)
self._stream = None
def stop(self) -> None:
if self._mgr is not None and self._stream is not None:
try:
self._mgr.release(self._device_index, self._loopback, engine_type=self._engine_type)
except Exception: # release is best-effort
pass
self._stream = None
self._smoothed = 0.0
def energy(self, smoothing: float = 0.4) -> float:
"""Return smoothed loudness in [0, 1] (0 when no capture/analysis yet).
``smoothing`` is the inertia of the EMA: 0 = instantaneous, 1 = sluggish.
"""
if self._stream is None:
return 0.0
analysis = self._stream.get_latest_analysis()
raw = 0.0
if analysis is not None:
raw = min(1.0, max(0.0, float(getattr(analysis, "rms", 0.0)) * _RMS_GAIN))
a = max(0.0, min(0.99, smoothing))
self._smoothed = self._smoothed * a + raw * (1.0 - a)
return self._smoothed
@@ -246,6 +246,13 @@ class ColorStripStreamManager:
# Inject gradient store for palette resolution # Inject gradient store for palette resolution
if self._gradient_store and hasattr(css_stream, "set_gradient_store"): if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
css_stream.set_gradient_store(self._gradient_store) css_stream.set_gradient_store(self._gradient_store)
# Inject audio capture deps for audio-reactive effects
if self._audio_capture_manager and hasattr(css_stream, "set_audio_capture"):
css_stream.set_audio_capture(
self._audio_capture_manager,
self._audio_source_store,
self._audio_template_store,
)
# Inject asset store for notification sound playback # Inject asset store for notification sound playback
if self._asset_store and hasattr(css_stream, "set_asset_store"): if self._asset_store and hasattr(css_stream, "set_asset_store"):
css_stream.set_asset_store(self._asset_store) css_stream.set_asset_store(self._asset_store)
@@ -10,7 +10,6 @@ to the user's location and the current season.
""" """
import datetime import datetime
import math
import threading import threading
import time import time
@@ -84,71 +83,16 @@ _daylight_lut: np.ndarray | None = None
# ── Solar position helpers ────────────────────────────────────────────── # ── Solar position helpers ──────────────────────────────────────────────
#
# ``compute_solar_times`` / ``utc_offset_hours_for`` moved to
def _compute_solar_times( # ``ledgrab.utils.solar`` (pure math, no project imports) so the automation
latitude: float, # engine can reuse them without importing this processing module. Imported
longitude: float, # under their old private names here so the rest of this file (and
day_of_year: int, # ``value_stream``, which imports them from here) is unchanged.
utc_offset_hours: float = 0.0, from ledgrab.utils.solar import ( # noqa: E402
) -> tuple: compute_solar_times as _compute_solar_times,
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time. utc_offset_hours_for as _utc_offset_hours_for,
)
Uses simplified NOAA solar equations:
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
- hour angle: cos(ha) = -tan(lat) * tan(decl)
- solar noon (UTC): 12 - longitude/15
- wall-clock sunrise/sunset: solar_noon_utc + utc_offset ha/15
Polar day and polar night are clamped to visible ranges.
"""
deg2rad = math.pi / 180.0
decl_deg = 23.45 * math.sin(2.0 * math.pi * (284 + day_of_year) / 365.0)
decl_rad = decl_deg * deg2rad
lat_rad = latitude * deg2rad
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
solar_noon_utc = 12.0 - longitude / 15.0
solar_noon_local = solar_noon_utc + utc_offset_hours
if cos_ha <= -1.0:
# Polar day — sun never sets; fake a long visible window
sunrise = solar_noon_local - 9.0
sunset = solar_noon_local + 9.0
elif cos_ha >= 1.0:
# Polar night — sun never rises; collapse to noon
sunrise = solar_noon_local
sunset = solar_noon_local
else:
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
sunrise = solar_noon_local - ha_hours
sunset = solar_noon_local + ha_hours
# Clamp to a safe range the LUT builder can render. With reasonable
# tz/longitude pairs sunrise lands in (3..10) and sunset in (14..21);
# we widen the clamp so weird tz/lon combinations still produce a
# usable curve instead of dividing by zero.
sunrise = max(0.5, min(11.5, sunrise))
sunset = max(12.5, min(23.5, sunset))
return sunrise, sunset
def _utc_offset_hours_for(tz_name: str, when: datetime.datetime | None = None) -> float:
"""Return the UTC offset (in hours) for the given IANA timezone.
Empty/unknown tz falls back to the system local offset for ``when``.
"""
when = when or datetime.datetime.now()
if tz_name and ZoneInfo is not None:
try:
offset = when.replace(tzinfo=None).astimezone(ZoneInfo(tz_name)).utcoffset()
if offset is not None:
return offset.total_seconds() / 3600.0
except ZoneInfoNotFoundError:
pass
local_offset = when.astimezone().utcoffset()
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray: def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
@@ -130,35 +130,41 @@ class DeviceHealthMixin:
"latency_ms": state.health.latency_ms, "latency_ms": state.health.latency_ms,
} }
) )
# Audit record for device online/offline transition. # Audit record for device online/offline transition — best-effort.
from ledgrab.core.activity_log.recorder import get_module_recorder # 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() rec = get_module_recorder()
if rec is not None: if rec is not None:
is_online = state.health.online is_online = state.health.online
# Best-effort name lookup from the device store. # Best-effort name lookup from the device store.
device_name: str | None = None device_name: str | None = None
try: try:
if self._device_store is not None: if self._device_store is not None:
device_name = self._device_store.get_device(device_id).name device_name = self._device_store.get_device(device_id).name
except Exception: except Exception:
pass pass
safe_name = sanitize_display(device_name) if device_name else None safe_name = sanitize_display(device_name) if device_name else None
display = safe_name or device_id display = safe_name or device_id
action = "device.online" if is_online else "device.offline" action = "device.online" if is_online else "device.offline"
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
status_word = "came online" if is_online else "went offline" status_word = "came online" if is_online else "went offline"
rec.record( rec.record(
category=ActivityCategory.DEVICE, category=ActivityCategory.DEVICE,
action=action, action=action,
severity=severity, severity=severity,
actor="system", actor="system",
entity_type="device", entity_type="device",
entity_id=device_id, entity_id=device_id,
entity_name=safe_name, entity_name=safe_name,
message=f"Device '{display}' {status_word}", message=f"Device '{display}' {status_word}",
metadata={"latency_ms": state.health.latency_ms}, metadata={"latency_ms": state.health.latency_ms},
) )
except Exception as e:
logger.debug("Device health: audit record failed for %s: %s", device_id, e)
# Auto-sync LED count # Auto-sync LED count
reported = state.health.device_led_count reported = state.health.device_led_count
@@ -304,6 +304,13 @@ class EffectColorStripStream(ColorStripStream):
# Sparkle rain state # Sparkle rain state
self._sparkle_state: np.ndarray | None = None # per-LED brightness 0..1 self._sparkle_state: np.ndarray | None = None # per-LED brightness 0..1
self._gradient_store = None # injected by stream manager self._gradient_store = None # injected by stream manager
# Audio-reactive modulation (tap injected by stream manager via
# set_audio_capture; defaults keep the effect working with no audio).
self._audio_tap = None
self._audio_reactive = False
self._reactive_mode = "brightness"
self._reactive_audio_source_id = ""
self._reactive_intensity = 0.7
self._update_from_source(source) self._update_from_source(source)
def set_gradient_store(self, gradient_store) -> None: def set_gradient_store(self, gradient_store) -> None:
@@ -344,9 +351,36 @@ class EffectColorStripStream(ColorStripStream):
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0) self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
self._scale = bfloat(getattr(source, "scale", 1.0), 1.0) self._scale = bfloat(getattr(source, "scale", 1.0), 1.0)
self._mirror = bool(getattr(source, "mirror", False)) self._mirror = bool(getattr(source, "mirror", False))
# Audio-reactive params
self._audio_reactive = bool(getattr(source, "audio_reactive", False))
self._reactive_mode = getattr(source, "reactive_mode", "brightness") or "brightness"
self._reactive_intensity = bfloat(getattr(source, "reactive_intensity", 0.7), 0.7)
self._reactive_audio_source_id = getattr(source, "reactive_audio_source_id", "") or ""
if self._audio_tap is not None:
self._audio_tap.configure(self._reactive_audio_source_id)
with self._colors_lock: with self._colors_lock:
self._colors: np.ndarray | None = None self._colors: np.ndarray | None = None
def set_audio_capture(
self, audio_capture_manager, audio_source_store=None, audio_template_store=None
) -> None:
"""Inject audio capture deps so the effect can react to loudness.
Called by the stream manager after construction (mirrors
``set_gradient_store``). Builds the tap and resolves the referenced
audio source; a missing manager just leaves the effect non-reactive.
"""
if audio_capture_manager is None:
return
from ledgrab.core.processing.audio_energy_tap import AudioEnergyTap
self._audio_tap = AudioEnergyTap(
audio_capture_manager, audio_source_store, audio_template_store
)
self._audio_tap.configure(self._reactive_audio_source_id)
def configure(self, device_led_count: int) -> None: def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0: if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count) new_count = max(self._led_count, device_led_count)
@@ -369,6 +403,8 @@ class EffectColorStripStream(ColorStripStream):
def start(self) -> None: def start(self) -> None:
if self._running: if self._running:
return return
if self._audio_reactive and self._audio_tap is not None:
self._audio_tap.start()
self._running = True self._running = True
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._animate_loop, target=self._animate_loop,
@@ -387,6 +423,8 @@ class EffectColorStripStream(ColorStripStream):
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("EffectColorStripStream animate thread did not terminate within 5s") logger.warning("EffectColorStripStream animate thread did not terminate within 5s")
self._thread = None self._thread = None
if self._audio_tap is not None:
self._audio_tap.stop()
self._heat = None self._heat = None
self._heat_n = 0 self._heat_n = 0
logger.info("EffectColorStripStream stopped") logger.info("EffectColorStripStream stopped")
@@ -399,12 +437,39 @@ class EffectColorStripStream(ColorStripStream):
from ledgrab.storage.color_strip_source import EffectColorStripSource from ledgrab.storage.color_strip_source import EffectColorStripSource
if isinstance(source, EffectColorStripSource): if isinstance(source, EffectColorStripSource):
# Release the audio capture before reconfiguring so a changed
# audio source / loopback is re-acquired cleanly.
tap_was_active = self._audio_tap is not None and self._audio_tap.active
if tap_was_active:
self._audio_tap.stop()
prev_led_count = self._led_count if self._auto_size else None prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source) self._update_from_source(source)
if prev_led_count and self._auto_size: if prev_led_count and self._auto_size:
self._led_count = prev_led_count self._led_count = prev_led_count
if self._running and self._audio_reactive and self._audio_tap is not None:
self._audio_tap.start()
logger.info("EffectColorStripStream params updated in-place") logger.info("EffectColorStripStream params updated in-place")
def _apply_audio_modulation(self, buf: np.ndarray) -> None:
"""Scale brightness and/or saturation of the rendered frame by loudness.
Quiet audio dims/desaturates toward ``1 - intensity``; loud audio drives
full brightness (and a saturation boost up to ``1 + intensity``).
"""
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: def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" """Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock self._clock = clock
@@ -476,6 +541,9 @@ class EffectColorStripStream(ColorStripStream):
continue continue
render_fn(self, buf, n, anim_time) render_fn(self, buf, n, anim_time)
if self._audio_reactive and self._audio_tap is not None:
self._apply_audio_modulation(buf)
with self._colors_lock: with self._colors_lock:
self._colors = buf self._colors = buf
except Exception as e: except Exception as e:
@@ -252,18 +252,24 @@ class PlaylistEngine:
order = self._resolve_order(playlist) order = self._resolve_order(playlist)
applied_any = False applied_any = False
for index, item in enumerate(order): for orig_index, item in order:
duration = clamp_duration(item.duration_seconds) duration = clamp_duration(item.duration_seconds)
if self._state is not None:
self._state.current_index = index
self._state.current_preset_id = item.scene_preset_id
self._state.step_started_at = datetime.now(timezone.utc)
self._state.step_duration = duration
applied = await self._apply_item(item.scene_preset_id) applied = await self._apply_item(item.scene_preset_id)
if applied: if applied:
# Only advertise runtime state for a step we actually applied
# and are about to dwell on — a missing/skipped preset must not
# briefly show up in get_state(). ``current_index`` is the
# ORIGINAL persisted items[] index (not the shuffled position)
# so clients can correlate it back to the items array.
if self._state is not None:
self._state.current_index = orig_index
self._state.current_preset_id = item.scene_preset_id
self._state.step_started_at = datetime.now(timezone.utc)
self._state.step_duration = duration
applied_any = True applied_any = True
self._fire_event("advanced", index=index, preset_id=item.scene_preset_id) self._fire_event("advanced", index=orig_index, preset_id=item.scene_preset_id)
# Only dwell on scenes we actually applied; skip missing ones # Only dwell on scenes we actually applied; skip missing ones
# immediately so the cycle doesn't stall on a dead reference. # immediately so the cycle doesn't stall on a dead reference.
await asyncio.sleep(duration) await asyncio.sleep(duration)
@@ -271,11 +277,16 @@ class PlaylistEngine:
return applied_any return applied_any
def _resolve_order(self, playlist: ScenePlaylist) -> List: def _resolve_order(self, playlist: ScenePlaylist) -> List:
if playlist.shuffle and len(playlist.items) > 1: """Return ``(orig_index, item)`` pairs, optionally shuffled.
shuffled = list(playlist.items)
random.shuffle(shuffled) # noqa: S311 - cosmetic ordering, not security The original persisted index travels with each item so ``current_index``
return shuffled in the runtime state always maps back into ``playlist.items`` even when
return list(playlist.items) shuffle reorders the cycle.
"""
indexed = list(enumerate(playlist.items))
if playlist.shuffle and len(indexed) > 1:
random.shuffle(indexed) # noqa: S311 - cosmetic ordering, not security
return indexed
async def _apply_item(self, preset_id: str) -> bool: async def _apply_item(self, preset_id: str) -> bool:
"""Apply one scene preset. Returns False if it could not be applied.""" """Apply one scene preset. Returns False if it could not be applied."""
+23 -1
View File
@@ -51,6 +51,7 @@ from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.core.scenes.playlist_engine import PlaylistEngine from ledgrab.core.scenes.playlist_engine import PlaylistEngine
from ledgrab.storage.game_integration_store import GameIntegrationStore from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
from ledgrab.core.game_integration.community_loader import register_community_adapters from ledgrab.core.game_integration.community_loader import register_community_adapters
from ledgrab.core.mqtt.mqtt_manager import MQTTManager from ledgrab.core.mqtt.mqtt_manager import MQTTManager
@@ -179,12 +180,16 @@ weather_manager = WeatherManager(weather_source_store)
ha_store = HomeAssistantStore(db) ha_store = HomeAssistantStore(db)
ha_manager = HomeAssistantManager(ha_store) ha_manager = HomeAssistantManager(ha_store)
mqtt_source_store = MQTTSourceStore(db) mqtt_source_store = MQTTSourceStore(db)
mqtt_manager = MQTTManager(mqtt_source_store) mqtt_manager = MQTTManager(
mqtt_source_store, automation_store=automation_store, version=__version__
)
http_endpoint_store = HTTPEndpointStore(db) http_endpoint_store = HTTPEndpointStore(db)
audio_processing_template_store = AudioProcessingTemplateStore(db) audio_processing_template_store = AudioProcessingTemplateStore(db)
game_integration_store = GameIntegrationStore(db) game_integration_store = GameIntegrationStore(db)
pattern_template_store = PatternTemplateStore(db) pattern_template_store = PatternTemplateStore(db)
game_event_bus = GameEventBus() game_event_bus = GameEventBus()
# Owns LoL Live-Client-Data poll threads (League is poll-only, not GSI-push).
lol_poll_manager = LoLPollManager(game_event_bus)
register_community_adapters() register_community_adapters()
# Activity log repository — constructed at module level like other stores so # Activity log repository — constructed at module level like other stores so
@@ -369,6 +374,7 @@ async def lifespan(app: FastAPI):
ha_manager=ha_manager, ha_manager=ha_manager,
game_integration_store=game_integration_store, game_integration_store=game_integration_store,
game_event_bus=game_event_bus, game_event_bus=game_event_bus,
lol_poll_manager=lol_poll_manager,
mqtt_store=mqtt_source_store, mqtt_store=mqtt_source_store,
mqtt_manager=mqtt_manager, mqtt_manager=mqtt_manager,
http_endpoint_store=http_endpoint_store, http_endpoint_store=http_endpoint_store,
@@ -420,6 +426,9 @@ async def lifespan(app: FastAPI):
# Start automation engine (evaluates conditions and activates scenes) # Start automation engine (evaluates conditions and activates scenes)
await automation_engine.start() await automation_engine.start()
# Publish Home Assistant MQTT discovery for any source that opted in.
await mqtt_manager.bootstrap_discovery()
# Start auto-backup engine (periodic configuration backups) # Start auto-backup engine (periodic configuration backups)
await auto_backup_engine.start() await auto_backup_engine.start()
@@ -429,6 +438,13 @@ async def lifespan(app: FastAPI):
# Start update checker (periodic release polling) # Start update checker (periodic release polling)
await update_service.start() await update_service.start()
# Start LoL Live-Client-Data pollers for any enabled League integrations
# (League is poll-only; GSI games push into the ingest endpoint instead).
try:
lol_poll_manager.sync(game_integration_store.get_all_integrations())
except Exception as e:
logger.error(f"Failed to start LoL pollers: {e}")
# Start OS notification listener (Windows toast → notification CSS streams) # Start OS notification listener (Windows toast → notification CSS streams)
os_notif_listener = OsNotificationListener( os_notif_listener = OsNotificationListener(
color_strip_store=color_strip_store, color_strip_store=color_strip_store,
@@ -502,6 +518,12 @@ async def lifespan(app: FastAPI):
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown. # Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0) await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
# Stop LoL poller threads so they stop publishing game events.
try:
lol_poll_manager.stop_all()
except Exception as e:
logger.error(f"Error stopping LoL pollers: {e}")
# Tear down any active calibration session BEFORE stop_all so the device # 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. # isn't left stuck in the white-chase and its prior target is restored.
# stop() is a no-op when no session is active. # stop() is a no-op when no session is active.
@@ -196,6 +196,37 @@
width: 100%; width: 100%;
} }
/* ── Solar (sunrise/sunset) rule ───────────────────────────── */
.solar-event-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.solar-event-row select {
flex: 1 1 8rem;
min-width: 8rem;
}
.solar-event-row input[type="number"] {
width: 5rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.solar-offset-unit {
font-size: 0.8rem;
color: var(--text-muted);
}
.solar-coord-row {
display: flex;
gap: 10px;
}
.solar-coord-row .rule-field {
flex: 1;
}
.solar-coord-row input[type="number"] {
width: 100%;
}
.time-range-label { .time-range-label {
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
+41
View File
@@ -3261,6 +3261,46 @@
user-select: none; user-select: none;
} }
/* Color-harmony generator (gradient editor) */
.gradient-harmony-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.gradient-harmony-row input[type="color"] {
width: 38px;
height: 30px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 6px;
background: none;
cursor: pointer;
flex-shrink: 0;
}
.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, #gradient-canvas,
#ge-gradient-canvas { #ge-gradient-canvas {
width: 100%; 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="filters"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="routing"] { animation-delay: 0.06s; } .ds-section[data-ds-key="routing"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="output"] { animation-delay: 0.10s; } .ds-section[data-ds-key="output"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="power"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="filtering"] { animation-delay: 0.14s; } .ds-section[data-ds-key="filtering"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="broker"] { animation-delay: 0.06s; } .ds-section[data-ds-key="broker"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="protocol"] { animation-delay: 0.10s; } .ds-section[data-ds-key="protocol"] { animation-delay: 0.10s; }
+4 -2
View File
@@ -107,7 +107,7 @@ import {
import { import {
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal, loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationRule, saveAutomationEditor, addAutomationRule,
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl, toggleAutomationEnabled, triggerAutomationNow, cloneAutomation, deleteAutomation, copyWebhookUrl,
} from './features/automations.ts'; } from './features/automations.ts';
import { import {
showGameIntegrationEditor, saveGameIntegration, closeGameIntegrationModal, showGameIntegrationEditor, saveGameIntegration, closeGameIntegrationModal,
@@ -150,7 +150,7 @@ import {
// Layer 5: color-strip sources // Layer 5: color-strip sources
import { import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip, showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange, onCSSTypeChange, onEffectTypeChange, onEffectReactiveToggle, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
compositeAddLayer, compositeRemoveLayer, compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone, mappedAddZone, mappedRemoveZone,
onAudioVizChange, onAudioVizChange,
@@ -505,6 +505,7 @@ Object.assign(window, {
saveAutomationEditor, saveAutomationEditor,
addAutomationRule, addAutomationRule,
toggleAutomationEnabled, toggleAutomationEnabled,
triggerAutomationNow,
cloneAutomation, cloneAutomation,
deleteAutomation, deleteAutomation,
copyWebhookUrl, copyWebhookUrl,
@@ -586,6 +587,7 @@ Object.assign(window, {
deleteColorStrip, deleteColorStrip,
onCSSTypeChange, onCSSTypeChange,
onEffectTypeChange, onEffectTypeChange,
onEffectReactiveToggle,
onEffectPaletteChange, onEffectPaletteChange,
onCSSClockChange, onCSSClockChange,
onAnimationTypeChange, onAnimationTypeChange,
+15 -2
View File
@@ -7,6 +7,9 @@ import { IconSelect } from './icon-select.ts';
let currentLocale = 'en'; let currentLocale = 'en';
let _localeIconSelect: IconSelect | null = null; let _localeIconSelect: IconSelect | null = null;
let translations = {}; let translations = {};
// English baseline kept in memory so a key missing from the active locale
// degrades to readable English rather than a raw dotted identifier.
let baseTranslations = {};
let _initialized = false; let _initialized = false;
const supportedLocales = { const supportedLocales = {
@@ -37,9 +40,12 @@ export function t(key: string, params: Record<string, any> = {}) {
let text; let text;
if ('count' in params) { if ('count' in params) {
const form = getPluralForm(currentLocale, params.count); const form = getPluralForm(currentLocale, params.count);
text = translations[`${key}.${form}`] || translations[key] || fallbackTranslations[key] || key; const enForm = getPluralForm('en', params.count);
text = translations[`${key}.${form}`] || translations[key]
|| baseTranslations[`${key}.${enForm}`] || baseTranslations[`${key}.${form}`]
|| baseTranslations[key] || fallbackTranslations[key] || key;
} else { } else {
text = translations[key] || fallbackTranslations[key] || key; text = translations[key] || baseTranslations[key] || fallbackTranslations[key] || key;
} }
Object.keys(params).forEach(param => { Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
@@ -80,6 +86,13 @@ export async function setLocale(locale: string) {
} }
translations = await loadTranslations(locale); translations = await loadTranslations(locale);
// Keep an English baseline so any key missing from a non-English locale
// resolves to English instead of a raw dotted key (see t()).
if (locale === 'en') {
baseTranslations = translations;
} else if (Object.keys(baseTranslations).length === 0) {
baseTranslations = await loadTranslations('en');
}
currentLocale = locale; currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale); document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale); document.documentElement.setAttribute('lang', locale);
@@ -81,6 +81,11 @@ let _liveEventListener: ((e: Event) => void) | null = null;
let _loadingDelayTimer: ReturnType<typeof setTimeout> | null = null; let _loadingDelayTimer: ReturnType<typeof setTimeout> | null = null;
let _showSpinner = false; let _showSpinner = false;
let _hasLoadedOnce = 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 = { const _filters: ActiveFilters = {
categories: [], categories: [],
@@ -178,14 +183,26 @@ export function localizeMessage(entry: ActivityEntry): string {
// ─── Build query string from active filters + cursor ──────── // ─── 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 { function _buildQuery(beforeSeq: number | null = null): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const cat of _filters.categories) params.append('categories', cat); for (const cat of _filters.categories) params.append('categories', cat);
for (const sev of _filters.severities) params.append('severities', sev); for (const sev of _filters.severities) params.append('severities', sev);
if (_filters.actor) params.set('actor', _filters.actor); if (_filters.actor) params.set('actor', _filters.actor);
if (_filters.entity_type) params.set('entity_type', _filters.entity_type); if (_filters.entity_type) params.set('entity_type', _filters.entity_type);
if (_filters.since) params.set('since', _filters.since); if (_filters.since) params.set('since', _localToUtcIso(_filters.since));
if (_filters.until) params.set('until', _filters.until); if (_filters.until) params.set('until', _localToUtcIso(_filters.until));
if (_filters.q) params.set('q', _filters.q); if (_filters.q) params.set('q', _filters.q);
if (beforeSeq != null) params.set('before_seq', String(beforeSeq)); if (beforeSeq != null) params.set('before_seq', String(beforeSeq));
params.set('limit', '50'); params.set('limit', '50');
@@ -392,7 +409,7 @@ function _renderList(): string {
<span>${escapeHtml(t('activity_log.live'))}</span> <span>${escapeHtml(t('activity_log.live'))}</span>
</div> </div>
</div> </div>
<div class="al-list" role="log" aria-label="${escapeHtml(t('activity_log.title'))}" aria-live="polite"> <div class="al-list" role="log" aria-label="${escapeHtml(t('activity_log.title'))}">
${rows} ${rows}
</div> </div>
${loadMore}`; ${loadMore}`;
@@ -475,6 +492,17 @@ function _attachDelegatedClicks(): void {
if (entryId) activityLogToggleDetail(entryId); 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 ─────────────────────────────────────── // ─── Full panel render ───────────────────────────────────────
@@ -636,11 +664,24 @@ function _entryPassesFilters(entry: ActivityEntry): boolean {
function _prependLiveEntry(entry: ActivityEntry): void { function _prependLiveEntry(entry: ActivityEntry): void {
if (!_entryPassesFilters(entry)) return; 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]; _entries = [entry, ..._entries];
_total = _total + 1; _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) // 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) { if (list) {
const html = _renderEntryRow(entry, true); const html = _renderEntryRow(entry, true);
list.insertAdjacentHTML('afterbegin', html); list.insertAdjacentHTML('afterbegin', html);
@@ -649,6 +690,9 @@ function _prependLiveEntry(entry: ActivityEntry): void {
if (firstRow) { if (firstRow) {
requestAnimationFrame(() => { firstRow.classList.add('al-entry-appear'); }); 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 // Update count badge
const countEl = list.closest('.al-panel')?.querySelector('.al-count'); const countEl = list.closest('.al-panel')?.querySelector('.al-count');
if (countEl) countEl.textContent = t('activity_log.n_entries', { n: _total }); if (countEl) countEl.textContent = t('activity_log.n_entries', { n: _total });
@@ -683,7 +727,16 @@ export function activityLogToggleDetail(entryId: string): void {
if (!row) return; if (!row) return;
const entry = _entries.find(e => e.id === entryId); const entry = _entries.find(e => e.id === entryId);
if (!entry) return; if (!entry) return;
// Was the keyboard focus inside the row we're about to replace? outerHTML
// destroys the focused .al-entry-row node, so focus would fall to <body>.
const wasFocused = row.contains(document.activeElement);
row.outerHTML = _renderEntryRow(entry, false); row.outerHTML = _renderEntryRow(entry, false);
if (wasFocused) {
// Restore focus to the recreated row so keyboard users keep their place.
const newRow = panel.querySelector<HTMLElement>(
`[data-al-id="${CSS.escape(entryId)}"] .al-entry-row[data-toggle-id]`);
newRow?.focus();
}
} }
export function activityLogToggleCat(cat: string): void { export function activityLogToggleCat(cat: string): void {
@@ -756,10 +809,13 @@ export function activityLogPreset(key: string): void {
switch (key) { switch (key) {
case 'today': { case 'today': {
const todayStart = new Date(); const d = new Date();
todayStart.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0);
// datetime-local format: YYYY-MM-DDTHH:MM // Build a LOCAL-wall-clock datetime-local string (YYYY-MM-DDTHH:MM)
_filters.since = todayStart.toISOString().slice(0, 16); // 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; break;
} }
case 'errors': case 'errors':
@@ -863,8 +919,13 @@ export async function loadActivityLog(): Promise<void> {
_startLiveUpdates(); _startLiveUpdates();
ensureRelativeTimeTicker(); ensureRelativeTimeTicker();
// Re-render on language change (baked-in t() calls) // Re-render on language change (baked-in t() calls). Guard so re-entering
document.addEventListener('languageChanged', _onLanguageChanged); // the tab (loadActivityLog runs on every tab activation) doesn't stack
// duplicate listeners.
if (!_languageListenerAttached) {
document.addEventListener('languageChanged', _onLanguageChanged);
_languageListenerAttached = true;
}
} }
function _onLanguageChanged(): void { function _onLanguageChanged(): void {
@@ -490,13 +490,17 @@ export async function autoCalSweepForward(): Promise<void> {
_state.busy = true; _state.busy = true;
try { try {
await _setPosition(next); await _setPosition(next);
// The user may have cancelled (unmount nulls _state) during the await.
if (!_state) return;
_state.currentIndex = next; _state.currentIndex = next;
_state.errorMsg = ''; _state.errorMsg = '';
} catch (err: unknown) { } catch (err: unknown) {
_state.errorMsg = _errMsg(err); if (_state) _state.errorMsg = _errMsg(err);
} finally { } finally {
_state.busy = false; if (_state) {
_render(); _state.busy = false;
_render();
}
} }
} }
@@ -511,13 +515,16 @@ export async function autoCalSweepBack(): Promise<void> {
_state.busy = true; _state.busy = true;
try { try {
await _setPosition(prev); await _setPosition(prev);
if (!_state) return;
_state.currentIndex = prev; _state.currentIndex = prev;
_state.errorMsg = ''; _state.errorMsg = '';
} catch (err: unknown) { } catch (err: unknown) {
_state.errorMsg = _errMsg(err); if (_state) _state.errorMsg = _errMsg(err);
} finally { } finally {
_state.busy = false; if (_state) {
_render(); _state.busy = false;
_render();
}
} }
} }
@@ -530,12 +537,12 @@ export async function autoCalMarkCorner(): Promise<void> {
_state.busy = true; _state.busy = true;
try { try {
await _setPosition(next); await _setPosition(next);
_state.currentIndex = next; if (_state) _state.currentIndex = next;
} catch { /* best effort */ } finally { } catch { /* best effort */ } finally {
_state.busy = false; if (_state) _state.busy = false;
} }
} }
_render(); if (_state) _render();
} }
export async function autoCalBackToDirection(): Promise<void> { export async function autoCalBackToDirection(): Promise<void> {
@@ -564,11 +571,14 @@ export async function autoCalSolve(): Promise<void> {
offset: 0, offset: 0,
}, { errorMessage: t('autocal.error.solve_failed') }); }, { errorMessage: t('autocal.error.solve_failed') });
if (!_state) return;
_state.solved = solved; _state.solved = solved;
// Stop the chase session — device restored to prior target // Stop the chase session — device restored to prior target
await _stopSession(); await _stopSession();
if (!_state) return;
_state.step = 'preview'; _state.step = 'preview';
} catch (err: unknown) { } catch (err: unknown) {
if (!_state) return;
_state.errorMsg = _errMsg(err); _state.errorMsg = _errMsg(err);
_state.busy = false; _state.busy = false;
_render(); _render();
@@ -683,6 +693,8 @@ export async function autoCalSave(): Promise<void> {
if (onComplete) onComplete(); if (onComplete) onComplete();
} catch (err: unknown) { } catch (err: unknown) {
// The user may have cancelled (unmount nulls _state) during the await.
if (!_state) return;
_state.busy = false; _state.busy = false;
_state.errorMsg = _errMsg(err); _state.errorMsg = _errMsg(err);
if (btn) btn.removeAttribute('disabled'); if (btn) btn.removeAttribute('disabled');
@@ -126,6 +126,10 @@ class AutomationEditorModal extends Modal {
deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value, deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value, deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value,
tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []), tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []),
actionUrl: (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value || '',
actionMethod: (document.getElementById('automation-action-method') as HTMLSelectElement)?.value || '',
actionFireOn: (document.getElementById('automation-action-fire-on') as HTMLSelectElement)?.value || '',
actionBody: (document.getElementById('automation-action-body') as HTMLTextAreaElement)?.value || '',
}; };
} }
} }
@@ -344,6 +348,7 @@ type RuleChipBuilder = (c: any) => ModChipOpts;
the scene activation. */ the scene activation. */
const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = { const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }), startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }),
manual_trigger: () => ({ icon: _icon(P.zap), text: t('automations.rule.manual_trigger') }),
application: (c) => { application: (c) => {
const apps = (c.apps || []).join(', ') || '—'; const apps = (c.apps || []).join(', ') || '—';
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running')); const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
@@ -358,6 +363,22 @@ const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
if (c.timezone) text += ` · ${c.timezone}`; if (c.timezone) text += ` · ${c.timezone}`;
return { icon: ICON_CLOCK, text, title: t('automations.rule.time_of_day') }; 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) => { system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active'); 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') }; return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
@@ -516,7 +537,18 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
}); });
} }
const chipsHtml = `<div class="mod-chips">${ruleChain}${_chainArrow('→')}${sceneChipHtml}${deactivationHtml}</div>`; // ── Optional webhook-action chip — shows when an outbound webhook fires. ──
let actionHtml = '';
const _webhook = (automation.actions || []).find((a: any) => a.action_type === 'webhook');
if (_webhook && _webhook.webhook_url) {
actionHtml = _chainArrow('→') + _chipHtml({
icon: ICON_WEB,
text: t('automations.action.webhook'),
title: t(`automations.action.fire_on.${_webhook.fire_on || 'activate'}`),
});
}
const chipsHtml = `<div class="mod-chips">${ruleChain}${_chainArrow('→')}${sceneChipHtml}${deactivationHtml}${actionHtml}</div>`;
// ── State surfaces: LED + patch indicator ── // ── State surfaces: LED + patch indicator ──
// Active = blink (live signal); Enabled-but-idle = off (waiting); // Active = blink (live signal); Enabled-but-idle = off (waiting);
@@ -544,6 +576,8 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
// "AUTO · 07" pattern (last 2 hex chars, uppercase). ── // "AUTO · 07" pattern (last 2 hex chars, uppercase). ──
const shortId = (automation.id || '').replace(/^auto_/i, '').slice(-2).toUpperCase() || 'NA'; 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 = { const mod: ModCardOpts = {
running: automation.is_active, running: automation.is_active,
head: { head: {
@@ -564,6 +598,15 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
foot: { foot: {
patchState, patchState,
patchLabel, patchLabel,
primaryAction: hasManual
? {
label: t('automations.action.trigger'),
icon: ICON_START,
onclick: `triggerAutomationNow('${automation.id}')`,
title: t('automations.trigger.tooltip'),
variant: 'go',
}
: undefined,
secondaryActions: [ secondaryActions: [
automation.enabled automation.enabled
? { label: t('automations.action.disable'), icon: ICON_PAUSE, onclick: `toggleAutomationEnabled('${automation.id}', false)`, variant: 'stop' } ? { label: t('automations.action.disable'), icon: ICON_PAUSE, onclick: `toggleAutomationEnabled('${automation.id}', false)`, variant: 'stop' }
@@ -605,6 +648,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
_ensureRuleLogicIconSelect(); _ensureRuleLogicIconSelect();
_ensureDeactivationModeIconSelect(); _ensureDeactivationModeIconSelect();
_ensureActionIconSelects();
// Fetch scenes for selector // Fetch scenes for selector
try { try {
@@ -615,6 +659,8 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = 'none'; (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = 'none';
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none'); if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
(document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = 'none'; (document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = 'none';
// Reset webhook action fields (overwritten below for edit/clone).
_loadAutomationAction([]);
let _editorTags: any[] = []; let _editorTags: any[] = [];
@@ -642,6 +688,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode); if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
_onDeactivationModeChange(); _onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id); _initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
_loadAutomationAction(automation.actions || []);
_editorTags = automation.tags || []; _editorTags = automation.tags || [];
} catch (e: any) { } catch (e: any) {
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -670,6 +717,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode); if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
_onDeactivationModeChange(); _onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id); _initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
_loadAutomationAction(cloneData.actions || []);
_editorTags = cloneData.tags || []; _editorTags = cloneData.tags || [];
} else { } else {
titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`; titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
@@ -684,6 +732,8 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
// Wire up deactivation mode change // Wire up deactivation mode change
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).onchange = _onDeactivationModeChange; (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).onchange = _onDeactivationModeChange;
// Wire up webhook URL → show/hide the rest of the action fields
(document.getElementById('automation-action-webhook-url') as HTMLInputElement).oninput = _onActionUrlChange;
// Auto-name wiring // Auto-name wiring
_autoNameManuallyEdited = !!(automationId || cloneData); _autoNameManuallyEdited = !!(automationId || cloneData);
@@ -777,6 +827,56 @@ function _ensureDeactivationModeIconSelect() {
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any); _deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
} }
// ── Webhook action selectors (method + fire_on) ──
let _actionMethodIconSelect: any = null;
let _actionFireOnIconSelect: any = null;
const _ACTION_METHOD_ICONS: any = { POST: P.send, PUT: P.code, GET: P.globe };
const _ACTION_FIRE_ON_ICONS: any = { activate: P.play, deactivate: P.undo2, both: P.zap };
function _ensureActionIconSelects() {
const methodSel = document.getElementById('automation-action-method');
if (methodSel && !_actionMethodIconSelect) {
const items = ['POST', 'PUT', 'GET'].map(k => ({
value: k,
icon: _icon(_ACTION_METHOD_ICONS[k]),
label: k,
}));
_actionMethodIconSelect = new IconSelect({ target: methodSel, items, columns: 3 } as any);
}
const fireSel = document.getElementById('automation-action-fire-on');
if (fireSel && !_actionFireOnIconSelect) {
const items = ['activate', 'deactivate', 'both'].map(k => ({
value: k,
icon: _icon(_ACTION_FIRE_ON_ICONS[k]),
label: t(`automations.action.fire_on.${k}`),
}));
_actionFireOnIconSelect = new IconSelect({ target: fireSel, items, columns: 3 } as any);
}
}
// Show the method/fire_on/body fields only once a webhook URL is present.
function _onActionUrlChange() {
const url = (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value.trim() || '';
const fields = document.getElementById('automation-action-fields');
if (fields) (fields as HTMLElement).style.display = url ? '' : 'none';
}
// Populate the webhook action fields from an automation's first webhook action.
function _loadAutomationAction(actions: any[]) {
const webhook = (actions || []).find((a: any) => a.action_type === 'webhook') || null;
const urlEl = document.getElementById('automation-action-webhook-url') as HTMLInputElement;
const methodEl = document.getElementById('automation-action-method') as HTMLSelectElement;
const fireEl = document.getElementById('automation-action-fire-on') as HTMLSelectElement;
const bodyEl = document.getElementById('automation-action-body') as HTMLTextAreaElement;
if (urlEl) urlEl.value = webhook?.webhook_url || '';
if (methodEl) methodEl.value = webhook?.method || 'POST';
if (_actionMethodIconSelect) _actionMethodIconSelect.setValue(webhook?.method || 'POST');
if (fireEl) fireEl.value = webhook?.fire_on || 'activate';
if (_actionFireOnIconSelect) _actionFireOnIconSelect.setValue(webhook?.fire_on || 'activate');
if (bodyEl) bodyEl.value = webhook?.body_template || '';
_onActionUrlChange();
}
// ===== Condition editor ===== // ===== Condition editor =====
export function addAutomationRule() { export function addAutomationRule() {
@@ -784,10 +884,10 @@ export function addAutomationRule() {
_autoGenerateAutomationName(); _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 = { const RULE_TYPE_ICONS = {
startup: P.power, application: P.smartphone, startup: P.power, manual_trigger: P.zap, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor, 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, mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
http_poll: P.globe, http_poll: P.globe,
}; };
@@ -885,6 +985,10 @@ function _renderStartupFields(container: HTMLElement, _data: any): void {
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`; container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
} }
function _renderManualTriggerFields(container: HTMLElement, _data: any): void {
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.manual_trigger.hint')}</small>`;
}
function _renderTimeOfDayFields(container: HTMLElement, data: any): void { function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
const startTime = data.start_time || '00:00'; const startTime = data.start_time || '00:00';
const endTime = data.end_time || '23:59'; const endTime = data.end_time || '23:59';
@@ -936,6 +1040,68 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
}); });
} }
function _renderSolarFields(container: HTMLElement, data: any): void {
const startEvent = data.start_event === 'sunrise' ? 'sunrise' : 'sunset';
const endEvent = data.end_event === 'sunset' ? 'sunset' : 'sunrise';
const startOff = Number.isFinite(data.start_offset_minutes) ? data.start_offset_minutes : 0;
const endOff = Number.isFinite(data.end_offset_minutes) ? data.end_offset_minutes : 0;
const lat = Number.isFinite(data.latitude) ? data.latitude : 50;
const lon = Number.isFinite(data.longitude) ? data.longitude : 0;
const days: number[] = Array.isArray(data.days_of_week) ? data.days_of_week : [];
const tz: string = data.timezone || '';
const dayChips = [0, 1, 2, 3, 4, 5, 6]
.map((d) => `<button type="button" class="weekday-chip${days.includes(d) ? ' active' : ''}" data-day="${d}">${t('weekday.short.' + d)}</button>`)
.join('');
const eventOpts = (sel: string) => `
<option value="sunrise" ${sel === 'sunrise' ? 'selected' : ''}>${t('automations.rule.solar.event.sunrise')}</option>
<option value="sunset" ${sel === 'sunset' ? 'selected' : ''}>${t('automations.rule.solar.event.sunset')}</option>`;
container.innerHTML = `
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.solar.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.solar.start')}</label>
<div class="solar-event-row">
<select class="rule-solar-start-event">${eventOpts(startEvent)}</select>
<input type="number" class="rule-solar-start-offset" min="-1439" max="1439" step="5" value="${startOff}" title="${escapeHtml(t('automations.rule.solar.offset'))}">
<span class="solar-offset-unit">${t('automations.rule.solar.offset.unit')}</span>
</div>
</div>
<div class="rule-field">
<label>${t('automations.rule.solar.end')}</label>
<div class="solar-event-row">
<select class="rule-solar-end-event">${eventOpts(endEvent)}</select>
<input type="number" class="rule-solar-end-offset" min="-1439" max="1439" step="5" value="${endOff}" title="${escapeHtml(t('automations.rule.solar.offset'))}">
<span class="solar-offset-unit">${t('automations.rule.solar.offset.unit')}</span>
</div>
</div>
<div class="solar-coord-row">
<div class="rule-field">
<label>${t('automations.rule.solar.latitude')}</label>
<input type="number" class="rule-solar-latitude" min="-90" max="90" step="0.0001" value="${lat}">
</div>
<div class="rule-field">
<label>${t('automations.rule.solar.longitude')}</label>
<input type="number" class="rule-solar-longitude" min="-180" max="180" step="0.0001" value="${lon}">
</div>
</div>
<small class="rule-hint-desc">${t('automations.rule.solar.location_hint')}</small>
<div class="rule-weekday-block">
<span class="rule-field-label">${t('automations.rule.time_of_day.days')}</span>
<div class="weekday-chips">${dayChips}</div>
<small class="rule-hint-desc">${t('automations.rule.time_of_day.days_hint')}</small>
</div>
<div class="rule-tz-block">
<label class="rule-field-label">${t('automations.rule.time_of_day.timezone')}</label>
<input type="text" class="rule-timezone" placeholder="${escapeHtml(t('automations.rule.time_of_day.timezone.placeholder'))}" value="${escapeHtml(tz)}">
</div>
</div>`;
enhanceMiniSelects(container, 'select.rule-solar-start-event');
enhanceMiniSelects(container, 'select.rule-solar-end-event');
container.querySelectorAll('.weekday-chip').forEach((chip) => {
chip.addEventListener('click', () => chip.classList.toggle('active'));
});
}
function _renderSystemIdleFields(container: HTMLElement, data: any): void { function _renderSystemIdleFields(container: HTMLElement, data: any): void {
const idleMinutes = data.idle_minutes ?? 5; const idleMinutes = data.idle_minutes ?? 5;
const whenIdle = data.when_idle ?? true; const whenIdle = data.when_idle ?? true;
@@ -1254,8 +1420,10 @@ function _renderApplicationFields(container: HTMLElement, data: any): void {
const RULE_FIELD_RENDERERS: Record<RuleType, RuleFieldRenderer> = { const RULE_FIELD_RENDERERS: Record<RuleType, RuleFieldRenderer> = {
startup: _renderStartupFields, startup: _renderStartupFields,
manual_trigger: _renderManualTriggerFields,
application: _renderApplicationFields, application: _renderApplicationFields,
time_of_day: _renderTimeOfDayFields, time_of_day: _renderTimeOfDayFields,
solar: _renderSolarFields,
system_idle: _renderSystemIdleFields, system_idle: _renderSystemIdleFields,
display_state: _renderDisplayStateFields, display_state: _renderDisplayStateFields,
mqtt: _renderMqttFields, mqtt: _renderMqttFields,
@@ -1340,6 +1508,7 @@ type RuleCollector = (row: Element) => Record<string, any>;
const RULE_COLLECTORS: Record<RuleType, RuleCollector> = { const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
startup: () => ({ rule_type: 'startup' }), startup: () => ({ rule_type: 'startup' }),
manual_trigger: () => ({ rule_type: 'manual_trigger' }),
time_of_day: (row) => ({ time_of_day: (row) => ({
rule_type: 'time_of_day', rule_type: 'time_of_day',
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00', start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
@@ -1348,6 +1517,22 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
.map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)), .map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)),
timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(), 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) => ({ system_idle: (row) => ({
rule_type: 'system_idle', rule_type: 'system_idle',
idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5, idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5,
@@ -1449,6 +1634,15 @@ export async function saveAutomationEditor() {
return; return;
} }
const actionUrl = (document.getElementById('automation-action-webhook-url') as HTMLInputElement)?.value.trim() || '';
const actions = actionUrl ? [{
action_type: 'webhook',
webhook_url: actionUrl,
method: (document.getElementById('automation-action-method') as HTMLSelectElement)?.value || 'POST',
fire_on: (document.getElementById('automation-action-fire-on') as HTMLSelectElement)?.value || 'activate',
body_template: (document.getElementById('automation-action-body') as HTMLTextAreaElement)?.value || '',
}] : [];
const body = { const body = {
name, name,
enabled: enabledInput.checked, enabled: enabledInput.checked,
@@ -1458,6 +1652,7 @@ export async function saveAutomationEditor() {
deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value, deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null, deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null,
tags: _automationTagsInput ? _automationTagsInput.getValue() : [], tags: _automationTagsInput ? _automationTagsInput.getValue() : [],
actions,
}; };
const automationId = idInput.value; const automationId = idInput.value;
@@ -1494,6 +1689,27 @@ export async function toggleAutomationEnabled(automationId: any, enable: any) {
} }
} }
export async function triggerAutomationNow(automationId: any) {
try {
const r = await apiPost<{ status: string; errors: string[] }>(
`/automations/${automationId}/trigger`, undefined, {
errorMessage: t('automations.error.trigger_failed'),
});
if (r.status === 'skipped') {
showToast(t('automations.trigger.skipped'), 'warning');
} else if (r.errors && r.errors.length) {
showToast(`${t('automations.trigger.partial')} (${r.errors.length})`, 'warning');
} else {
showToast(t('automations.triggered'), 'success');
}
automationsCacheObj.invalidate();
loadAutomations();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t('automations.error.trigger_failed'), 'error');
}
}
export function copyWebhookUrl(btn: any) { export function copyWebhookUrl(btn: any) {
const input = btn.closest('.webhook-url-row').querySelector('.rule-webhook-url') as HTMLInputElement; const input = btn.closest('.webhook-url-row').querySelector('.rule-webhook-url') as HTMLInputElement;
if (!input || !input.value) return; if (!input || !input.value) return;
@@ -936,6 +936,10 @@ function _populateRoiInputs(calibration: any): void {
set('cal-roi-y', pct(calibration.roi_y, 0)); set('cal-roi-y', pct(calibration.roi_y, 0));
set('cal-roi-width', pct(calibration.roi_width, 1)); set('cal-roi-width', pct(calibration.roi_width, 1));
set('cal-roi-height', pct(calibration.roi_height, 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() { 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_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_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, 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 { try {
@@ -13,6 +13,7 @@ import { TagInput, renderTagChips } from '../../core/tag-input.ts';
import { escapeHtml } from '../../core/api.ts'; import { escapeHtml } from '../../core/api.ts';
import { import {
gradientInit, gradientRenderAll, getGradientStops, gradientSetIdPrefix, gradientInit, gradientRenderAll, getGradientStops, gradientSetIdPrefix,
gradientWireHarmony,
} from '../css-gradient-editor.ts'; } from '../css-gradient-editor.ts';
/* ── Helpers ──────────────────────────────────────────────────── */ /* ── Helpers ──────────────────────────────────────────────────── */
@@ -180,6 +181,7 @@ export async function showGradientModal(editId: string | null = null, cloneData:
requestAnimationFrame(() => { requestAnimationFrame(() => {
gradientSetIdPrefix('ge-'); gradientSetIdPrefix('ge-');
gradientInit(stops); gradientInit(stops);
gradientWireHarmony();
gradientEditorModal.snapshot(); gradientEditorModal.snapshot();
}); });
} }
@@ -22,6 +22,9 @@ import type { ColorStripSource } from '../../types.ts';
import { TagInput } from '../../core/tag-input.ts'; import { TagInput } from '../../core/tag-input.ts';
import { IconSelect, showTypePicker, type IconSelectItem } from '../../core/icon-select.ts'; import { IconSelect, showTypePicker, type IconSelectItem } from '../../core/icon-select.ts';
import { EntitySelect } from '../../core/entity-palette.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 { BindableScalarWidget } from '../../core/bindable-scalar.ts';
import { BindableColorWidget } from '../../core/bindable-color.ts'; import { BindableColorWidget } from '../../core/bindable-color.ts';
import { getBaseOrigin } from '../settings.ts'; import { getBaseOrigin } from '../settings.ts';
@@ -568,6 +571,8 @@ let _animationTypeIconSelect: any = null;
let _interpolationIconSelect: any = null; let _interpolationIconSelect: any = null;
let _effectTypeIconSelect: any = null; let _effectTypeIconSelect: any = null;
let _effectPaletteEntitySelect: EntitySelect | null = null; let _effectPaletteEntitySelect: EntitySelect | null = null;
let _effectReactiveSourceEntitySelect: EntitySelect | null = null;
let _effectReactiveModeEnhanced = false;
let _gradientPresetEntitySelect: EntitySelect | null = null; let _gradientPresetEntitySelect: EntitySelect | null = null;
let _gradientEasingIconSelect: any = null; let _gradientEasingIconSelect: any = null;
let _candleTypeIconSelect: any = null; let _candleTypeIconSelect: any = null;
@@ -637,6 +642,50 @@ function _ensureEffectIntensityWidget(): BindableScalarWidget {
return _effectIntensityWidget; return _effectIntensityWidget;
} }
/* ── Effect audio-reactive controls ───────────────────────────── */
/** Show/hide the reactive options group and lazily populate the source list. */
export function onEffectReactiveToggle() {
const on = (document.getElementById('css-editor-effect-audio-reactive') as HTMLInputElement)?.checked;
const group = document.getElementById('css-editor-effect-reactive-group');
if (group) group.style.display = on ? '' : 'none';
if (on) {
void _populateEffectReactiveSource();
const modeSel = document.getElementById('css-editor-effect-reactive-mode');
if (modeSel && !_effectReactiveModeEnhanced) {
enhanceMiniSelects(modeSel.parentElement || document, '#css-editor-effect-reactive-mode');
_effectReactiveModeEnhanced = true;
}
}
}
/** Build the EntitySelect for the reactive audio-source picker from the cache.
* Pass `desired` to select a specific source after (re)building the options. */
async function _populateEffectReactiveSource(desired?: string) {
const select = document.getElementById('css-editor-effect-reactive-source') as HTMLSelectElement | null;
if (!select) return;
try {
const sources: any[] = await audioSourcesCache.fetch();
const target = desired !== undefined ? desired : select.value;
select.innerHTML = sources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
if (target) select.value = target;
if (_effectReactiveSourceEntitySelect) { _effectReactiveSourceEntitySelect.destroy(); _effectReactiveSourceEntitySelect = null; }
if (sources.length > 0) {
_effectReactiveSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => sources.map(s => ({
value: s.id, label: s.name,
icon: getAudioSourceIcon(s.source_type), desc: s.source_type,
})),
placeholder: t('palette.search'),
});
if (target) _effectReactiveSourceEntitySelect.setValue(target);
}
} catch {
select.innerHTML = '';
}
}
function _ensureEffectScaleWidget(): BindableScalarWidget { function _ensureEffectScaleWidget(): BindableScalarWidget {
if (!_effectScaleWidget) { if (!_effectScaleWidget) {
_effectScaleWidget = new BindableScalarWidget({ _effectScaleWidget = new BindableScalarWidget({
@@ -1041,6 +1090,23 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
_ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0); _ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
_ensureEffectScaleWidget().setValue(css.scale ?? 1.0); _ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false; (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(); onEffectPaletteChange();
}, },
reset() { reset() {
@@ -1051,6 +1117,11 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
_ensureEffectIntensityWidget().setValue(1.0); _ensureEffectIntensityWidget().setValue(1.0);
_ensureEffectScaleWidget().setValue(1.0); _ensureEffectScaleWidget().setValue(1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false; (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) { getPayload(name) {
const payload: any = { const payload: any = {
@@ -1060,6 +1131,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
intensity: _ensureEffectIntensityWidget().getValue(), intensity: _ensureEffectIntensityWidget().getValue(),
scale: _ensureEffectScaleWidget().getValue(), scale: _ensureEffectScaleWidget().getValue(),
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked, 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)) { if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) {
payload.color = _ensureEffectColorWidget().getValue(); payload.color = _ensureEffectColorWidget().getValue();
@@ -212,6 +212,112 @@ export function applyGradientPreset(key: string): void {
gradientInit(GRADIENT_PRESETS[key]); gradientInit(GRADIENT_PRESETS[key]);
} }
/* ── Color harmony generator ──────────────────────────────────── */
export type HarmonyType =
| 'complementary' | 'analogous' | 'triadic'
| 'split_complementary' | 'tetradic' | 'monochromatic';
export const HARMONY_TYPES: HarmonyType[] = [
'complementary', 'analogous', 'triadic',
'split_complementary', 'tetradic', 'monochromatic',
];
/** RGB (0255) → HSV with h in [0,360), s/v in [0,1]. */
function _rgbToHsv(rgb: number[]): [number, number, number] {
const r = rgb[0] / 255, g = rgb[1] / 255, b = rgb[2] / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
const s = max === 0 ? 0 : d / max;
return [h, s, max];
}
/** HSV (h in [0,360), s/v in [0,1]) → RGB (0255). */
function _hsvToRgb(h: number, s: number, v: number): number[] {
h = ((h % 360) + 360) % 360;
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
return [r, g, b].map(ch => Math.round((ch + m) * 255));
}
const _HARMONY_ROTATIONS: Partial<Record<HarmonyType, number[]>> = {
complementary: [0, 180],
analogous: [-30, 0, 30],
triadic: [0, 120, 240],
split_complementary: [0, 150, 210],
tetradic: [0, 90, 180, 270],
};
/**
* Generate evenly-spaced gradient stops from a base color using classic
* color-theory relationships. Monochromatic ramps the value of a single hue;
* every other type rotates the hue by fixed angles and keeps S/V.
*/
export function generateHarmonyStops(baseRgb: number[], type: HarmonyType): GradientPresetStop[] {
const [h, s, v] = _rgbToHsv(baseRgb);
let colors: number[][];
if (type === 'monochromatic') {
const steps = 5;
colors = Array.from({ length: steps }, (_, i) =>
_hsvToRgb(h, Math.min(1, s + 0.05 * (steps - 1 - i)), 0.3 + (0.7 * i) / (steps - 1)),
);
} else {
const rotations = _HARMONY_ROTATIONS[type] || [0, 180];
colors = rotations.map(r => _hsvToRgb(h + r, s, v));
}
const n = colors.length;
return colors.map((color, i) => ({
position: n > 1 ? Math.round((i / (n - 1)) * 100) / 100 : 0,
color,
}));
}
/** Replace the current gradient with a harmony generated from `baseRgb`. */
export function applyColorHarmony(baseRgb: number[], type: HarmonyType): void {
gradientInit(generateHarmonyStops(baseRgb, type));
}
/**
* Wire the harmony controls in the prefixed editor scope (base color input +
* one button per harmony type). Idempotent safe to call on every modal open.
*/
export function gradientWireHarmony(): void {
const container = _el('gradient-harmony-types');
const baseInput = _el('gradient-harmony-base') as HTMLInputElement | null;
if (!container || !baseInput) return;
// Seed the base picker from the current gradient's first stop.
if (_gradientStops.length > 0) {
baseInput.value = rgbArrayToHex(_gradientStops[0].color);
}
if ((container as any)._harmonyBound) return;
(container as any)._harmonyBound = true;
container.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('[data-harmony]') as HTMLElement | null;
if (!btn) return;
const type = btn.dataset.harmony as HarmonyType;
if (!HARMONY_TYPES.includes(type)) return;
applyColorHarmony(hexToRgbArray(baseInput.value), type);
});
}
/* ── Render ───────────────────────────────────────────────────── */ /* ── Render ───────────────────────────────────────────────────── */
export function gradientRenderAll(): void { export function gradientRenderAll(): void {
@@ -792,10 +792,22 @@ function _reconcileDynamicSections(dynamic: HTMLElement, newHtml: string): void
// If the new HTML contains non-section top-level nodes (e.g. the // If the new HTML contains non-section top-level nodes (e.g. the
// `.dashboard-no-targets` placeholder shown when there are no entities), // `.dashboard-no-targets` placeholder shown when there are no entities),
// fall back to a simple innerHTML swap — this path is rare and the // fall back to an innerHTML swap. BUT the empty-entities (first-run) state
// no-entities state doesn't have live widgets worth preserving. // 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; const totalTopLevel = scratch.children.length;
if (totalTopLevel !== incoming.length) { if (totalTopLevel !== incoming.length) {
const liveRa = dynamic.querySelector<HTMLElement>('#dashboard-recent-activity-list.dal-list');
if (liveRa) {
const scratchRa = scratch.querySelector<HTMLElement>('#dashboard-recent-activity-list');
if (scratchRa) scratchRa.replaceWith(liveRa.cloneNode(true));
const merged = scratch.innerHTML;
if (dynamic.innerHTML !== merged) dynamic.innerHTML = merged;
return;
}
if (dynamic.innerHTML !== newHtml) dynamic.innerHTML = newHtml; if (dynamic.innerHTML !== newHtml) dynamic.innerHTML = newHtml;
return; return;
} }
@@ -1010,6 +1010,8 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
if (lmi && cloneData.lifx_min_interval_ms != null) { if (lmi && cloneData.lifx_min_interval_ms != null) {
lmi.value = String(cloneData.lifx_min_interval_ms); lmi.value = String(cloneData.lifx_min_interval_ms);
} }
const lpz = document.getElementById('device-lifx-per-zone') as HTMLInputElement | null;
if (lpz) lpz.checked = !!cloneData.lifx_per_zone;
} }
// Prefill Nanoleaf fields (clone only carries the rate limit — the // Prefill Nanoleaf fields (clone only carries the rate limit — the
// token is not exposed in /devices responses, so a cloned device // token is not exposed in /devices responses, so a cloned device
@@ -1019,6 +1021,8 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
if (nmi && cloneData.nanoleaf_min_interval_ms != null) { if (nmi && cloneData.nanoleaf_min_interval_ms != null) {
nmi.value = String(cloneData.nanoleaf_min_interval_ms); 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 // Prefill Govee fields
if (isGoveeDevice(presetType)) { if (isGoveeDevice(presetType)) {
@@ -1229,6 +1233,7 @@ export async function handleAddDevice(event: any) {
body.hue_username = (document.getElementById('device-hue-username') as HTMLInputElement)?.value || ''; body.hue_username = (document.getElementById('device-hue-username') as HTMLInputElement)?.value || '';
body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || ''; body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || '';
body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || ''; body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || '';
body.hue_gradient_mode = (document.getElementById('device-hue-gradient-mode') as HTMLInputElement | null)?.checked ?? true;
} }
if (isYeelightDevice(deviceType)) { if (isYeelightDevice(deviceType)) {
const raw = (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value; const raw = (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value;
@@ -1244,6 +1249,7 @@ export async function handleAddDevice(event: any) {
const raw = (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value; const raw = (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '50', 10); const parsed = parseInt(raw || '50', 10);
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
body.lifx_per_zone = (document.getElementById('device-lifx-per-zone') as HTMLInputElement | null)?.checked || false;
} }
if (isGoveeDevice(deviceType)) { if (isGoveeDevice(deviceType)) {
const raw = (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value; const raw = (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value;
@@ -1254,6 +1260,7 @@ export async function handleAddDevice(event: any) {
const raw = (document.getElementById('device-nanoleaf-min-interval') as HTMLInputElement)?.value; const raw = (document.getElementById('device-nanoleaf-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '100', 10); const parsed = parseInt(raw || '100', 10);
body.nanoleaf_min_interval_ms = Number.isFinite(parsed) ? parsed : 100; 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)) { if (isBleDevice(deviceType)) {
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e'; body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
@@ -1611,7 +1618,7 @@ function _showEspnowFields(show: boolean) {
} }
function _showHueFields(show: boolean) { function _showHueFields(show: boolean) {
const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group']; const ids = ['device-hue-username-group', 'device-hue-client-key-group', 'device-hue-group-id-group', 'device-hue-gradient-mode-group'];
ids.forEach(id => { ids.forEach(id => {
const el = document.getElementById(id) as HTMLElement; const el = document.getElementById(id) as HTMLElement;
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
@@ -1636,6 +1643,8 @@ function _showWizFields(show: boolean) {
function _showLifxFields(show: boolean) { function _showLifxFields(show: boolean) {
const el = document.getElementById('device-lifx-min-interval-group') as HTMLElement | null; const el = document.getElementById('device-lifx-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
const pz = document.getElementById('device-lifx-per-zone-group') as HTMLElement | null;
if (pz) pz.style.display = show ? '' : 'none';
} }
function _showGoveeFields(show: boolean) { function _showGoveeFields(show: boolean) {
@@ -1654,6 +1663,8 @@ function _showGoveeFields(show: boolean) {
function _showNanoleafFields(show: boolean) { function _showNanoleafFields(show: boolean) {
const el = document.getElementById('device-nanoleaf-min-interval-group') as HTMLElement | null; const el = document.getElementById('device-nanoleaf-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none'; 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; const submitBtn = document.getElementById('add-device-submit-btn') as HTMLButtonElement | null;
if (submitBtn) { if (submitBtn) {
if (show) { if (show) {
@@ -701,10 +701,14 @@ export async function showSettings(deviceId: any) {
// (HSBK averaged from the strip). LIFX recommends ≤20 cmd/sec per // (HSBK averaged from the strip). LIFX recommends ≤20 cmd/sec per
// device; default 50 ms matches that ceiling. // device; default 50 ms matches that ceiling.
const lifxMinIntervalGroup = document.getElementById('settings-lifx-min-interval-group'); const lifxMinIntervalGroup = document.getElementById('settings-lifx-min-interval-group');
const lifxPerZoneGroup = document.getElementById('settings-lifx-per-zone-group');
if (isLifxDevice(device.device_type)) { if (isLifxDevice(device.device_type)) {
if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = ''; if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = '';
const lmi = device.lifx_min_interval_ms ?? 50; const lmi = device.lifx_min_interval_ms ?? 50;
(document.getElementById('settings-lifx-min-interval') as HTMLInputElement).value = String(lmi); (document.getElementById('settings-lifx-min-interval') as HTMLInputElement).value = String(lmi);
if (lifxPerZoneGroup) (lifxPerZoneGroup as HTMLElement).style.display = '';
const lpzEl = document.getElementById('settings-lifx-per-zone') as HTMLInputElement | null;
if (lpzEl) lpzEl.checked = !!device.lifx_per_zone;
// Relabel URL field as IP Address (same pattern as WiZ/Yeelight/DMX/DDP) // Relabel URL field as IP Address (same pattern as WiZ/Yeelight/DMX/DDP)
const urlLabel6 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null; const urlLabel6 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
const urlHint6 = urlGroup.querySelector('.input-hint') as HTMLElement | null; const urlHint6 = urlGroup.querySelector('.input-hint') as HTMLElement | null;
@@ -713,6 +717,7 @@ export async function showSettings(deviceId: any) {
urlInput.placeholder = t('device.lifx.url.placeholder') || '192.168.1.50'; urlInput.placeholder = t('device.lifx.url.placeholder') || '192.168.1.50';
} else { } else {
if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = 'none'; if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = 'none';
if (lifxPerZoneGroup) (lifxPerZoneGroup as HTMLElement).style.display = 'none';
} }
// Govee-specific fields — 2023+ LAN API over UDP fire-and-forget // Govee-specific fields — 2023+ LAN API over UDP fire-and-forget
@@ -746,6 +751,10 @@ export async function showSettings(deviceId: any) {
if (nanoleafMinIntervalGroup) (nanoleafMinIntervalGroup as HTMLElement).style.display = ''; if (nanoleafMinIntervalGroup) (nanoleafMinIntervalGroup as HTMLElement).style.display = '';
const nmi = device.nanoleaf_min_interval_ms ?? 100; const nmi = device.nanoleaf_min_interval_ms ?? 100;
(document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement).value = String(nmi); (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) { if (nanoleafPairedGroup) {
(nanoleafPairedGroup as HTMLElement).style.display = device.nanoleaf_paired ? '' : 'none'; (nanoleafPairedGroup as HTMLElement).style.display = device.nanoleaf_paired ? '' : 'none';
} }
@@ -923,6 +932,7 @@ export async function saveDeviceSettings() {
const raw = (document.getElementById('settings-lifx-min-interval') as HTMLInputElement | null)?.value; const raw = (document.getElementById('settings-lifx-min-interval') as HTMLInputElement | null)?.value;
const parsed = parseInt(raw || '50', 10); const parsed = parseInt(raw || '50', 10);
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
body.lifx_per_zone = (document.getElementById('settings-lifx-per-zone') as HTMLInputElement | null)?.checked || false;
} }
if (isGoveeDevice(settingsModal.deviceType)) { if (isGoveeDevice(settingsModal.deviceType)) {
const raw = (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value; const raw = (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value;
@@ -933,6 +943,7 @@ export async function saveDeviceSettings() {
const raw = (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement | null)?.value; const raw = (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement | null)?.value;
const parsed = parseInt(raw || '100', 10); const parsed = parseInt(raw || '100', 10);
body.nanoleaf_min_interval_ms = Number.isFinite(parsed) ? parsed : 100; 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 // Intentionally do NOT include nanoleaf_token here — the token
// is set once at pair time, encrypted at rest, and never // is set once at pair time, encrypted at rest, and never
// re-emitted from the settings modal. Re-pairing means // re-emitted from the settings modal. Re-pairing means
@@ -56,6 +56,8 @@ class MQTTSourceModal extends Modal {
base_topic: (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value, base_topic: (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value,
description: (document.getElementById('mqtt-source-description') as HTMLInputElement).value, description: (document.getElementById('mqtt-source-description') as HTMLInputElement).value,
tags: JSON.stringify(_mqttTagsInput ? _mqttTagsInput.getValue() : []), tags: JSON.stringify(_mqttTagsInput ? _mqttTagsInput.getValue() : []),
haDiscovery: (document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement)?.checked.toString() || 'false',
discoveryPrefix: (document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement)?.value || '',
}; };
} }
} }
@@ -79,6 +81,8 @@ export async function showMQTTSourceModal(editData: MQTTSource | null = null): P
(document.getElementById('mqtt-source-password') as HTMLInputElement).value = ''; // never expose (document.getElementById('mqtt-source-password') as HTMLInputElement).value = ''; // never expose
(document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = editData.client_id || 'ledgrab'; (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = editData.client_id || 'ledgrab';
(document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = editData.base_topic || 'ledgrab'; (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = editData.base_topic || 'ledgrab';
(document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked = !!editData.publish_ha_discovery;
(document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value = editData.discovery_prefix || 'homeassistant';
(document.getElementById('mqtt-source-description') as HTMLInputElement).value = editData.description || ''; (document.getElementById('mqtt-source-description') as HTMLInputElement).value = editData.description || '';
} else { } else {
(document.getElementById('mqtt-source-name') as HTMLInputElement).value = ''; (document.getElementById('mqtt-source-name') as HTMLInputElement).value = '';
@@ -88,9 +92,20 @@ export async function showMQTTSourceModal(editData: MQTTSource | null = null): P
(document.getElementById('mqtt-source-password') as HTMLInputElement).value = ''; (document.getElementById('mqtt-source-password') as HTMLInputElement).value = '';
(document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = 'ledgrab'; (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = 'ledgrab';
(document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = 'ledgrab'; (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = 'ledgrab';
(document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked = false;
(document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value = 'homeassistant';
(document.getElementById('mqtt-source-description') as HTMLInputElement).value = ''; (document.getElementById('mqtt-source-description') as HTMLInputElement).value = '';
} }
// Show the discovery-prefix field only while discovery is enabled.
const _discoveryToggle = document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement;
const _syncDiscoveryPrefix = () => {
const grp = document.getElementById('mqtt-source-discovery-prefix-group');
if (grp) (grp as HTMLElement).style.display = _discoveryToggle?.checked ? '' : 'none';
};
if (_discoveryToggle) _discoveryToggle.onchange = _syncDiscoveryPrefix;
_syncDiscoveryPrefix();
// Tags // Tags
if (_mqttTagsInput) { _mqttTagsInput.destroy(); _mqttTagsInput = null; } if (_mqttTagsInput) { _mqttTagsInput.destroy(); _mqttTagsInput = null; }
_mqttTagsInput = new TagInput(document.getElementById('mqtt-source-tags-container'), { placeholder: t('tags.placeholder') }); _mqttTagsInput = new TagInput(document.getElementById('mqtt-source-tags-container'), { placeholder: t('tags.placeholder') });
@@ -125,6 +140,8 @@ export async function saveMQTTSource(): Promise<void> {
const password = (document.getElementById('mqtt-source-password') as HTMLInputElement).value; const password = (document.getElementById('mqtt-source-password') as HTMLInputElement).value;
const client_id = (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value.trim() || 'ledgrab'; const client_id = (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value.trim() || 'ledgrab';
const base_topic = (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value.trim() || 'ledgrab'; const base_topic = (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value.trim() || 'ledgrab';
const publish_ha_discovery = (document.getElementById('mqtt-source-ha-discovery') as HTMLInputElement).checked;
const discovery_prefix = (document.getElementById('mqtt-source-discovery-prefix') as HTMLInputElement).value.trim() || 'homeassistant';
const description = (document.getElementById('mqtt-source-description') as HTMLInputElement).value.trim() || null; const description = (document.getElementById('mqtt-source-description') as HTMLInputElement).value.trim() || null;
if (!name) { if (!name) {
@@ -138,6 +155,7 @@ export async function saveMQTTSource(): Promise<void> {
const payload: Record<string, any> = { const payload: Record<string, any> = {
name, broker_port, username, client_id, base_topic, description, name, broker_port, username, client_id, base_topic, description,
publish_ha_discovery, discovery_prefix,
tags: _mqttTagsInput ? _mqttTagsInput.getValue() : [], tags: _mqttTagsInput ? _mqttTagsInput.getValue() : [],
}; };
if (broker_host) payload.broker_host = broker_host; if (broker_host) payload.broker_host = broker_host;
@@ -146,7 +146,7 @@ registerIconEntityType('gradient', makeSimpleIconAdapter<any>({
endpointPrefix: '/gradients', endpointPrefix: '/gradients',
reload: _reloadStreams, reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.gradient', typeLabelKey: 'device.icon.entity.gradient',
typeLabelFallback: 'Gradient', typeLabelFallback: 'Palette',
cardSelectors: (id) => [`[data-card-section="gradients"] [data-id="${CSS.escape(id)}"]`], cardSelectors: (id) => [`[data-card-section="gradients"] [data-id="${CSS.escape(id)}"]`],
})); }));
@@ -6,9 +6,11 @@
*/ */
export type RuleType = export type RuleType =
| 'application' | 'time_of_day' | 'system_idle' | 'application' | 'time_of_day' | 'solar' | 'system_idle'
| 'display_state' | 'mqtt' | 'webhook' | 'startup' | 'display_state' | 'mqtt' | 'webhook' | 'startup'
| 'home_assistant' | 'http_poll'; | 'home_assistant' | 'http_poll' | 'manual_trigger';
export type SolarEvent = 'sunrise' | 'sunset';
export type HTTPPollOperator = export type HTTPPollOperator =
| 'equals' | 'not_equals' | 'contains' | 'regex' | 'equals' | 'not_equals' | 'contains' | 'regex'
@@ -20,6 +22,16 @@ export interface AutomationRule {
match_type?: string; match_type?: string;
start_time?: string; start_time?: string;
end_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; idle_minutes?: number;
when_idle?: boolean; when_idle?: boolean;
state?: string; state?: string;
@@ -36,6 +48,15 @@ export interface AutomationRule {
value?: string; value?: string;
} }
export interface AutomationAction {
action_type: 'webhook';
webhook_url?: string;
method?: 'POST' | 'PUT' | 'GET';
body_template?: string;
content_type?: string;
fire_on?: 'activate' | 'deactivate' | 'both';
}
export interface Automation { export interface Automation {
id: string; id: string;
name: string; name: string;
@@ -46,6 +67,7 @@ export interface Automation {
deactivation_mode: 'none' | 'revert' | 'fallback_scene'; deactivation_mode: 'none' | 'revert' | 'fallback_scene';
deactivation_scene_preset_id?: string; deactivation_scene_preset_id?: string;
tags: string[]; tags: string[];
actions?: AutomationAction[];
webhook_url?: string; webhook_url?: string;
is_active: boolean; is_active: boolean;
last_activated_at?: string; last_activated_at?: string;
@@ -38,12 +38,15 @@ export interface Device {
espnow_channel: number; espnow_channel: number;
hue_paired: boolean; hue_paired: boolean;
hue_entertainment_group_id: string; hue_entertainment_group_id: string;
hue_gradient_mode?: boolean;
yeelight_min_interval_ms: number; yeelight_min_interval_ms: number;
wiz_min_interval_ms: number; wiz_min_interval_ms: number;
lifx_min_interval_ms: number; lifx_min_interval_ms: number;
lifx_per_zone?: boolean;
govee_min_interval_ms: number; govee_min_interval_ms: number;
nanoleaf_paired: boolean; nanoleaf_paired: boolean;
nanoleaf_min_interval_ms: number; nanoleaf_min_interval_ms: number;
nanoleaf_per_panel?: boolean;
spi_speed_hz: number; spi_speed_hz: number;
spi_led_type: string; spi_led_type: string;
chroma_device_type: string; chroma_device_type: string;
@@ -12,6 +12,8 @@ export interface MQTTSource {
password_set: boolean; password_set: boolean;
client_id: string; client_id: string;
base_topic: string; base_topic: string;
publish_ha_discovery?: boolean;
discovery_prefix?: string;
connected: boolean; connected: boolean;
description?: string; description?: string;
tags: string[]; tags: string[];
+90 -24
View File
@@ -75,6 +75,8 @@
"activity_log.msg.audit_log.disabled": "Activity logging disabled", "activity_log.msg.audit_log.disabled": "Activity logging disabled",
"activity_log.msg.automation.activated": "Automation '{name}' activated", "activity_log.msg.automation.activated": "Automation '{name}' activated",
"activity_log.msg.automation.deactivated": "Automation '{name}' deactivated", "activity_log.msg.automation.deactivated": "Automation '{name}' deactivated",
"activity_log.msg.automation.webhook_fired": "Webhook for '{name}' fired",
"activity_log.msg.automation.triggered": "Automation '{name}' manually triggered",
"activity_log.msg.server.shutting_down": "Server shutting down", "activity_log.msg.server.shutting_down": "Server shutting down",
"activity_log.msg.server.restarting": "Server restart requested", "activity_log.msg.server.restarting": "Server restart requested",
"activity_log.msg.server.shutdown_requested": "Server shutdown requested", "activity_log.msg.server.shutdown_requested": "Server shutdown requested",
@@ -96,7 +98,7 @@
"activity_log.entity_type.scene_playlist": "Scene Playlist", "activity_log.entity_type.scene_playlist": "Scene Playlist",
"activity_log.entity_type.sync_clock": "Sync Clock", "activity_log.entity_type.sync_clock": "Sync Clock",
"activity_log.entity_type.template": "Template", "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.cspt": "Processing Template",
"activity_log.entity_type.audio_template": "Audio Template", "activity_log.entity_type.audio_template": "Audio Template",
"activity_log.entity_type.audio_processing_template": "Audio Processing Template", "activity_log.entity_type.audio_processing_template": "Audio Processing Template",
@@ -336,6 +338,7 @@
"automation.disabled": "Automation disabled", "automation.disabled": "Automation disabled",
"automation.enabled": "Automation enabled", "automation.enabled": "Automation enabled",
"automations.action.disable": "Disable", "automations.action.disable": "Disable",
"automations.action.trigger": "Trigger",
"automations.add": "Add Automation", "automations.add": "Add Automation",
"automations.created": "Automation created", "automations.created": "Automation created",
"automations.deactivation_mode": "Deactivation:", "automations.deactivation_mode": "Deactivation:",
@@ -360,6 +363,7 @@
"automations.error.name_required": "Name is required", "automations.error.name_required": "Name is required",
"automations.error.save_failed": "Failed to save automation", "automations.error.save_failed": "Failed to save automation",
"automations.error.toggle_failed": "Failed to toggle automation", "automations.error.toggle_failed": "Failed to toggle automation",
"automations.error.trigger_failed": "Failed to trigger automation",
"automations.last_activated": "Last activated", "automations.last_activated": "Last activated",
"automations.logic.all": "ALL", "automations.logic.all": "ALL",
"automations.logic.and": " AND ", "automations.logic.and": " AND ",
@@ -427,6 +431,9 @@
"automations.rule.http_poll.value": "Value", "automations.rule.http_poll.value": "Value",
"automations.rule.http_poll.value.placeholder": "playing", "automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.value_source": "HTTP Value Source", "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": "MQTT",
"automations.rule.mqtt.desc": "MQTT message", "automations.rule.mqtt.desc": "MQTT message",
"automations.rule.mqtt.hint": "Activate when an MQTT topic receives a matching payload", "automations.rule.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
@@ -447,6 +454,18 @@
"automations.rule.system_idle.when_active.desc": "Fires while the user is actively using the system", "automations.rule.system_idle.when_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": "When idle",
"automations.rule.system_idle.when_idle.desc": "Fires once the user has been idle past the timeout", "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": "Time of Day",
"automations.rule.time_of_day.days": "Active days", "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.", "automations.rule.time_of_day.days_hint": "Leave all unselected for every day. Overnight windows count toward the day they start on.",
@@ -480,11 +499,27 @@
"automations.scene.search_placeholder": "Search scenes...", "automations.scene.search_placeholder": "Search scenes...",
"automations.section.action": "Action", "automations.section.action": "Action",
"automations.section.deactivation": "Deactivation", "automations.section.deactivation": "Deactivation",
"automations.section.action": "Webhook",
"automations.action.webhook": "Webhook",
"automations.action.webhook_url": "Webhook URL:",
"automations.action.webhook_url.hint": "Optional. POST to a Discord / IFTTT / Zapier / Node-RED URL when this automation fires. LAN addresses are allowed; loopback and cloud-metadata are blocked. Leave empty for no webhook.",
"automations.action.method": "Method:",
"automations.action.fire_on": "Fire on:",
"automations.action.fire_on.activate": "When activated",
"automations.action.fire_on.deactivate": "When deactivated",
"automations.action.fire_on.both": "Both",
"automations.action.body_template": "Body template:",
"automations.action.body_template.hint": "JSON body for POST/PUT. Tokens: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.",
"automations.error.invalid_webhook_url": "Invalid or blocked webhook URL.",
"automations.section.triggers": "Triggers", "automations.section.triggers": "Triggers",
"automations.status.active": "Active", "automations.status.active": "Active",
"automations.status.disabled": "Disabled", "automations.status.disabled": "Disabled",
"automations.status.inactive": "Inactive", "automations.status.inactive": "Inactive",
"automations.title": "Automations", "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", "automations.updated": "Automation updated",
"bg.anim.toggle": "Toggle ambient background", "bg.anim.toggle": "Toggle ambient background",
"bindable.none": "None (static value)", "bindable.none": "None (static value)",
@@ -524,6 +559,10 @@
"calibration.advanced.switch_to_simple": "Switch to Simple", "calibration.advanced.switch_to_simple": "Switch to Simple",
"calibration.advanced.title": "Advanced Calibration", "calibration.advanced.title": "Advanced Calibration",
"calibration.border_width": "Border (px):", "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.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
"calibration.button.cancel": "Cancel", "calibration.button.cancel": "Cancel",
"calibration.button.save": "Save", "calibration.button.save": "Save",
@@ -764,6 +803,14 @@
"color_strip.effect.meteor": "Meteor", "color_strip.effect.meteor": "Meteor",
"color_strip.effect.meteor.desc": "Bright head travels along the strip with an exponential-decay tail", "color_strip.effect.meteor.desc": "Bright head travels along the strip with an exponential-decay tail",
"color_strip.effect.mirror": "Mirror:", "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.mirror.hint": "Bounce mode — the meteor reverses direction at strip ends instead of wrapping.",
"color_strip.effect.noise": "Noise", "color_strip.effect.noise": "Noise",
"color_strip.effect.noise.desc": "Scrolling fractal value noise mapped to a palette", "color_strip.effect.noise.desc": "Scrolling fractal value noise mapped to a palette",
@@ -812,8 +859,8 @@
"color_strip.gradient.easing.linear.desc": "Constant-rate blending between stops", "color_strip.gradient.easing.linear.desc": "Constant-rate blending between stops",
"color_strip.gradient.easing.step": "Step", "color_strip.gradient.easing.step": "Step",
"color_strip.gradient.easing.step.desc": "Hard jumps between colors with no blending", "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.error.no_gradient": "Please select a palette",
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops", "color_strip.gradient.min_stops": "Palette must have at least 2 stops",
"color_strip.gradient.position": "Position (0.01.0)", "color_strip.gradient.position": "Position (0.01.0)",
"color_strip.gradient.preset": "Preset:", "color_strip.gradient.preset": "Preset:",
"color_strip.gradient.preset.apply": "Apply", "color_strip.gradient.preset.apply": "Apply",
@@ -837,8 +884,17 @@
"color_strip.gradient.preset.warm": "Warm", "color_strip.gradient.preset.warm": "Warm",
"color_strip.gradient.preview": "Gradient:", "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.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": "Palette:",
"color_strip.gradient.select.hint": "Select a gradient from the library. Create and edit gradients in the Gradients tab.", "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": "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.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", "color_strip.gradient.stops_count": "stops",
@@ -1321,6 +1377,8 @@
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:", "device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
"device.hue_gradient_mode": "Map across segments:",
"device.hue_gradient_mode.hint": "Spread the strip across a gradient lightstrip's segments (channels) instead of one averaged colour per light. Auto-detected on connect; plain bulbs are unaffected.",
"device.hue.url": "Bridge IP:", "device.hue.url": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge", "device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:", "device.hue.username": "Bridge Username:",
@@ -1371,7 +1429,7 @@
"device.icon.entity.cspt": "Color-strip processing template", "device.icon.entity.cspt": "Color-strip processing template",
"device.icon.entity.device": "Device", "device.icon.entity.device": "Device",
"device.icon.entity.game_integration": "Game integration", "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_light_target": "HA light target",
"device.icon.entity.ha_source": "Home Assistant source", "device.icon.entity.ha_source": "Home Assistant source",
"device.icon.entity.http_endpoint": "HTTP endpoint", "device.icon.entity.http_endpoint": "HTTP endpoint",
@@ -1470,6 +1528,8 @@
"device.lifx.url.placeholder": "192.168.1.50", "device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "Min Update Interval:", "device.lifx_min_interval": "Min Update Interval:",
"device.lifx_min_interval.hint": "Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.", "device.lifx_min_interval.hint": "Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.",
"device.lifx_per_zone": "Per-zone streaming:",
"device.lifx_per_zone.hint": "Address individual zones (Z/Beam multizone) or pixels (Tile/Canvas matrix) instead of one averaged colour. Auto-detected on connect; older bulbs fall back to single colour.",
"device.metrics.actual_fps": "Actual FPS", "device.metrics.actual_fps": "Actual FPS",
"device.metrics.current_fps": "Current FPS", "device.metrics.current_fps": "Current FPS",
"device.metrics.device_fps": "Device refresh rate", "device.metrics.device_fps": "Device refresh rate",
@@ -1498,6 +1558,8 @@
"device.nanoleaf.url.placeholder": "192.168.1.50", "device.nanoleaf.url.placeholder": "192.168.1.50",
"device.nanoleaf_min_interval": "Min Update Interval:", "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_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": "IP Address:",
"device.opc.url.hint": "OPC receiver address. TCP port defaults to 7890.", "device.opc.url.hint": "OPC receiver address. TCP port defaults to 7890.",
"device.opc.url.placeholder": "192.168.1.50", "device.opc.url.placeholder": "192.168.1.50",
@@ -1806,26 +1868,26 @@
"game_integration.test.timeout": "No events received within timeout period.", "game_integration.test.timeout": "No events received within timeout period.",
"game_integration.test.waiting": "Waiting for events from game...", "game_integration.test.waiting": "Waiting for events from game...",
"game_integration.updated": "Game integration updated", "game_integration.updated": "Game integration updated",
"gradient.add": "Add Gradient", "gradient.add": "Add Palette",
"gradient.builtin": "Built-in", "gradient.builtin": "Built-in",
"gradient.cloned": "Gradient cloned", "gradient.cloned": "Palette cloned",
"gradient.confirm_delete": "Delete gradient \"{name}\"?", "gradient.confirm_delete": "Delete palette \"{name}\"?",
"gradient.create_name": "New gradient name:", "gradient.create_name": "New palette name:",
"gradient.created": "Gradient created", "gradient.created": "Palette created",
"gradient.deleted": "Gradient deleted", "gradient.deleted": "Palette deleted",
"gradient.description": "Description:", "gradient.description": "Description:",
"gradient.description.hint": "Optional description for this gradient.", "gradient.description.hint": "Optional description for this palette.",
"gradient.edit": "Edit Gradient", "gradient.edit": "Edit Palette",
"gradient.edit_name": "Rename gradient:", "gradient.edit_name": "Rename palette:",
"gradient.error.delete_failed": "Failed to delete gradient", "gradient.error.delete_failed": "Failed to delete palette",
"gradient.error.min_stops": "At least 2 color stops are required", "gradient.error.min_stops": "At least 2 color stops are required",
"gradient.error.name_required": "Name is required", "gradient.error.name_required": "Name is required",
"gradient.error.save_failed": "Failed to save gradient", "gradient.error.save_failed": "Failed to save palette",
"gradient.group.title": "Gradients", "gradient.group.title": "Palettes",
"gradient.name": "Name:", "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.stops_label": "stops",
"gradient.updated": "Gradient updated", "gradient.updated": "Palette updated",
"graph.action.connect": "Connect", "graph.action.connect": "Connect",
"graph.action.disconnect": "Disconnect", "graph.action.disconnect": "Disconnect",
"graph.action.move": "Move node", "graph.action.move": "Move node",
@@ -2044,6 +2106,9 @@
"mqtt_source.add": "Add MQTT Source", "mqtt_source.add": "Add MQTT Source",
"mqtt_source.base_topic": "Base Topic:", "mqtt_source.base_topic": "Base Topic:",
"mqtt_source.base_topic.hint": "Prefix for status and state topics, e.g. ledgrab/status", "mqtt_source.base_topic.hint": "Prefix for status and state topics, e.g. ledgrab/status",
"mqtt_source.ha_discovery": "Home Assistant discovery:",
"mqtt_source.ha_discovery.hint": "Publish homeassistant/.../config topics so MQTT-only Home Assistant installs get LedGrab automation + connectivity entities automatically.",
"mqtt_source.discovery_prefix": "Discovery prefix:",
"mqtt_source.broker_host": "Broker Host:", "mqtt_source.broker_host": "Broker Host:",
"mqtt_source.broker_host.hint": "MQTT broker hostname or IP address, e.g. 192.168.1.100", "mqtt_source.broker_host.hint": "MQTT broker hostname or IP address, e.g. 192.168.1.100",
"mqtt_source.broker_port": "Port:", "mqtt_source.broker_port": "Port:",
@@ -2323,7 +2388,7 @@
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.", "section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
"section.empty.devices": "No devices 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.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_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.ha_sources": "No Home Assistant sources yet. Click + to add one.",
"section.empty.http_endpoints": "No HTTP endpoints yet. Click + to add one.", "section.empty.http_endpoints": "No HTTP endpoints yet. Click + to add one.",
@@ -2581,7 +2646,7 @@
"settings.section.file": "File", "settings.section.file": "File",
"settings.section.filtering": "Filtering", "settings.section.filtering": "Filtering",
"settings.section.filters": "Filters", "settings.section.filters": "Filters",
"settings.section.gradient": "Gradient", "settings.section.gradient": "Palette",
"settings.section.hardware": "Hardware", "settings.section.hardware": "Hardware",
"settings.section.history": "History", "settings.section.history": "History",
"settings.section.identity": "Identity", "settings.section.identity": "Identity",
@@ -2597,6 +2662,7 @@
"settings.section.notif_permission": "OS Permission", "settings.section.notif_permission": "OS Permission",
"settings.section.offsets": "Offsets", "settings.section.offsets": "Offsets",
"settings.section.output": "Output", "settings.section.output": "Output",
"settings.section.power": "Power",
"settings.section.preview": "Preview", "settings.section.preview": "Preview",
"settings.section.protocol": "Protocol", "settings.section.protocol": "Protocol",
"settings.section.provider": "Provider", "settings.section.provider": "Provider",
@@ -2674,7 +2740,7 @@
"streams.group.color_strip": "Color Strips", "streams.group.color_strip": "Color Strips",
"streams.group.css_processing": "Processing Templates", "streams.group.css_processing": "Processing Templates",
"streams.group.game": "Game Integration", "streams.group.game": "Game Integration",
"streams.group.gradients": "Gradients", "streams.group.gradients": "Palettes",
"streams.group.home_assistant": "Home Assistant", "streams.group.home_assistant": "Home Assistant",
"streams.group.http": "HTTP", "streams.group.http": "HTTP",
"streams.group.mqtt": "MQTT", "streams.group.mqtt": "MQTT",
@@ -2970,7 +3036,7 @@
"tour.src.static": "Static Image — test your setup with image files instead of live capture.", "tour.src.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.sync": "Sync Clocks — shared timers that synchronize animations across multiple sources.",
"tour.src.templates": "Capture Templates — reusable capture configurations (resolution, FPS, crop).", "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.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.css": "Color Strips — define how screen regions map to LED segments.",
"tour.tgt.devices": "Devices — your LED controllers discovered on the network.", "tour.tgt.devices": "Devices — your LED controllers discovered on the network.",
+78 -9
View File
@@ -40,7 +40,10 @@
"activity_log.filter.since": "С", "activity_log.filter.since": "С",
"activity_log.filter.title": "Фильтры", "activity_log.filter.title": "Фильтры",
"activity_log.filter.until": "По", "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.load_more": "Загрузить ещё",
"activity_log.loading": "Загрузка журнала…", "activity_log.loading": "Загрузка журнала…",
"activity_log.n_entries": "Записей: {n}", "activity_log.n_entries": "Записей: {n}",
@@ -75,6 +78,8 @@
"activity_log.msg.audit_log.disabled": "Запись активности отключена", "activity_log.msg.audit_log.disabled": "Запись активности отключена",
"activity_log.msg.automation.activated": "Автоматизация '{name}' активирована", "activity_log.msg.automation.activated": "Автоматизация '{name}' активирована",
"activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована", "activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована",
"activity_log.msg.automation.webhook_fired": "Вебхук для '{name}' отправлен",
"activity_log.msg.automation.triggered": "Автоматизация '{name}' запущена вручную",
"activity_log.msg.server.shutting_down": "Сервер выключается", "activity_log.msg.server.shutting_down": "Сервер выключается",
"activity_log.msg.server.restarting": "Запрошен перезапуск сервера", "activity_log.msg.server.restarting": "Запрошен перезапуск сервера",
"activity_log.msg.server.shutdown_requested": "Запрошено выключение сервера", "activity_log.msg.server.shutdown_requested": "Запрошено выключение сервера",
@@ -96,7 +101,7 @@
"activity_log.entity_type.scene_playlist": "Плейлист сцен", "activity_log.entity_type.scene_playlist": "Плейлист сцен",
"activity_log.entity_type.sync_clock": "Синх-часы", "activity_log.entity_type.sync_clock": "Синх-часы",
"activity_log.entity_type.template": "Шаблон", "activity_log.entity_type.template": "Шаблон",
"activity_log.entity_type.gradient": "Градиент", "activity_log.entity_type.gradient": "Палитра",
"activity_log.entity_type.cspt": "Шаблон обработки", "activity_log.entity_type.cspt": "Шаблон обработки",
"activity_log.entity_type.audio_template": "Аудиошаблон", "activity_log.entity_type.audio_template": "Аудиошаблон",
"activity_log.entity_type.audio_processing_template": "Шаблон аудиообработки", "activity_log.entity_type.audio_processing_template": "Шаблон аудиообработки",
@@ -284,8 +289,8 @@
"auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к LED Grab.", "auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к LED Grab.",
"auth.placeholder": "Введите ваш API ключ...", "auth.placeholder": "Введите ваш API ключ...",
"auth.please_login": "Пожалуйста, войдите для просмотра", "auth.please_login": "Пожалуйста, войдите для просмотра",
"auth.prompt_enter": "Enter your API key:", "auth.prompt_enter": "Введите ваш API-ключ:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", "auth.prompt_update": "API-ключ уже задан. Введите новый ключ для замены или оставьте поле пустым, чтобы удалить:",
"auth.session_expired": "Ваша сессия истекла или API ключ недействителен. Пожалуйста, войдите снова.", "auth.session_expired": "Ваша сессия истекла или API ключ недействителен. Пожалуйста, войдите снова.",
"auth.success": "Вход выполнен успешно!", "auth.success": "Вход выполнен успешно!",
"auth.title": "Вход в LED Grab", "auth.title": "Вход в LED Grab",
@@ -336,6 +341,7 @@
"automation.disabled": "Автоматизация выключена", "automation.disabled": "Автоматизация выключена",
"automation.enabled": "Автоматизация включена", "automation.enabled": "Автоматизация включена",
"automations.action.disable": "Отключить", "automations.action.disable": "Отключить",
"automations.action.trigger": "Запустить",
"automations.add": "Добавить автоматизацию", "automations.add": "Добавить автоматизацию",
"automations.created": "Автоматизация создана", "automations.created": "Автоматизация создана",
"automations.deactivation_mode": "Деактивация:", "automations.deactivation_mode": "Деактивация:",
@@ -360,6 +366,7 @@
"automations.error.name_required": "Введите название", "automations.error.name_required": "Введите название",
"automations.error.save_failed": "Не удалось сохранить автоматизацию", "automations.error.save_failed": "Не удалось сохранить автоматизацию",
"automations.error.toggle_failed": "Не удалось переключить автоматизацию", "automations.error.toggle_failed": "Не удалось переключить автоматизацию",
"automations.error.trigger_failed": "Не удалось запустить автоматизацию",
"automations.last_activated": "Последняя активация", "automations.last_activated": "Последняя активация",
"automations.logic.all": "ВСЕ", "automations.logic.all": "ВСЕ",
"automations.logic.and": " И ", "automations.logic.and": " И ",
@@ -417,6 +424,9 @@
"automations.rule.http_poll.value": "Значение", "automations.rule.http_poll.value": "Значение",
"automations.rule.http_poll.value.placeholder": "playing", "automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.value_source": "HTTP источник-значения", "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": "MQTT",
"automations.rule.mqtt.desc": "MQTT сообщение", "automations.rule.mqtt.desc": "MQTT сообщение",
"automations.rule.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику", "automations.rule.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
@@ -437,6 +447,18 @@
"automations.rule.system_idle.when_active.desc": "Срабатывает, пока пользователь активно работает с системой", "automations.rule.system_idle.when_active.desc": "Срабатывает, пока пользователь активно работает с системой",
"automations.rule.system_idle.when_idle": "При бездействии", "automations.rule.system_idle.when_idle": "При бездействии",
"automations.rule.system_idle.when_idle.desc": "Срабатывает, когда пользователь не активен дольше тайм-аута", "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": "Время суток",
"automations.rule.time_of_day.days": "Активные дни", "automations.rule.time_of_day.days": "Активные дни",
"automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.", "automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.",
@@ -470,11 +492,27 @@
"automations.scene.search_placeholder": "Поиск сцен...", "automations.scene.search_placeholder": "Поиск сцен...",
"automations.section.action": "Действие", "automations.section.action": "Действие",
"automations.section.deactivation": "Деактивация", "automations.section.deactivation": "Деактивация",
"automations.section.action": "Вебхук",
"automations.action.webhook": "Вебхук",
"automations.action.webhook_url": "URL вебхука:",
"automations.action.webhook_url.hint": "Необязательно. Отправлять POST-запрос на URL Discord / IFTTT / Zapier / Node-RED при срабатывании автоматизации. Локальные (LAN) адреса разрешены; loopback и метаданные облака заблокированы. Оставьте пустым, чтобы отключить вебхук.",
"automations.action.method": "Метод:",
"automations.action.fire_on": "Срабатывать при:",
"automations.action.fire_on.activate": "При активации",
"automations.action.fire_on.deactivate": "При деактивации",
"automations.action.fire_on.both": "В обоих случаях",
"automations.action.body_template": "Шаблон тела:",
"automations.action.body_template.hint": "Тело JSON для POST/PUT. Токены: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.",
"automations.error.invalid_webhook_url": "Недопустимый или заблокированный URL вебхука.",
"automations.section.triggers": "Триггеры", "automations.section.triggers": "Триггеры",
"automations.status.active": "Активна", "automations.status.active": "Активна",
"automations.status.disabled": "Отключена", "automations.status.disabled": "Отключена",
"automations.status.inactive": "Неактивна", "automations.status.inactive": "Неактивна",
"automations.title": "Автоматизации", "automations.title": "Автоматизации",
"automations.trigger.partial": "Запущено с ошибками",
"automations.trigger.skipped": "Условия не выполнены — автоматизация не запущена",
"automations.trigger.tooltip": "Запустить эту автоматизацию сейчас (правила по-прежнему проверяются)",
"automations.triggered": "Автоматизация запущена",
"automations.updated": "Автоматизация обновлена", "automations.updated": "Автоматизация обновлена",
"bg.anim.toggle": "Анимированный фон", "bg.anim.toggle": "Анимированный фон",
"bindable.none": "Нет (статическое значение)", "bindable.none": "Нет (статическое значение)",
@@ -516,6 +554,10 @@
"calibration.advanced.switch_to_simple": "Простой режим", "calibration.advanced.switch_to_simple": "Простой режим",
"calibration.advanced.title": "Расширенная калибровка", "calibration.advanced.title": "Расширенная калибровка",
"calibration.border_width": "Граница (px):", "calibration.border_width": "Граница (px):",
"calibration.linear_blend": "Смешивание в линейном свете",
"calibration.linear_blend.hint": "Усреднять пиксели границы в линейном свете для перцептивно корректного, более яркого смешивания цветов.",
"calibration.dither": "Дизеринг",
"calibration.dither.hint": "Пространственно-временной дизеринг уменьшает заметные полосы на плавных градиентах.",
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", "calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
"calibration.button.cancel": "Отмена", "calibration.button.cancel": "Отмена",
"calibration.button.save": "Сохранить", "calibration.button.save": "Сохранить",
@@ -728,6 +770,14 @@
"color_strip.effect.meteor": "Метеор", "color_strip.effect.meteor": "Метеор",
"color_strip.effect.meteor.desc": "Яркая точка движется по ленте с экспоненциально затухающим хвостом", "color_strip.effect.meteor.desc": "Яркая точка движется по ленте с экспоненциально затухающим хвостом",
"color_strip.effect.mirror": "Отражение:", "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.mirror.hint": "Режим отскока — метеор меняет направление у краёв ленты вместо переноса.",
"color_strip.effect.noise": "Шум", "color_strip.effect.noise": "Шум",
"color_strip.effect.noise.desc": "Прокручиваемый фрактальный шум, отображённый на палитру", "color_strip.effect.noise.desc": "Прокручиваемый фрактальный шум, отображённый на палитру",
@@ -760,7 +810,7 @@
"color_strip.gamma.hint": "Гамма-коррекция (1=без коррекции, \u003c1=ярче средние тона, \u003e1=темнее средние тона)", "color_strip.gamma.hint": "Гамма-коррекция (1=без коррекции, \u003c1=ярче средние тона, \u003e1=темнее средние тона)",
"color_strip.gradient.add_stop": "+ Добавить", "color_strip.gradient.add_stop": "+ Добавить",
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.", "color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок", "color_strip.gradient.min_stops": "Палитра должна содержать не менее 2 остановок",
"color_strip.gradient.position": "Позиция (0.01.0)", "color_strip.gradient.position": "Позиция (0.01.0)",
"color_strip.gradient.preset": "Пресет:", "color_strip.gradient.preset": "Пресет:",
"color_strip.gradient.preset.apply": "Применить", "color_strip.gradient.preset.apply": "Применить",
@@ -784,6 +834,15 @@
"color_strip.gradient.preset.warm": "Тёплый", "color_strip.gradient.preset.warm": "Тёплый",
"color_strip.gradient.preview": "Градиент:", "color_strip.gradient.preview": "Градиент:",
"color_strip.gradient.preview.hint": "Предпросмотр градиента. Нажмите на дорожку маркеров чтобы добавить остановку. Перетащите маркеры для изменения позиции.", "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": "Цветовые остановки:",
"color_strip.gradient.stops.hint": "Каждая остановка задаёт цвет в относительной позиции (0.0 = начало, 1.0 = конец). Кнопка ↔ добавляет цвет справа для создания резкого перехода.", "color_strip.gradient.stops.hint": "Каждая остановка задаёт цвет в относительной позиции (0.0 = начало, 1.0 = конец). Кнопка ↔ добавляет цвет справа для создания резкого перехода.",
"color_strip.gradient.stops_count": "остановок", "color_strip.gradient.stops_count": "остановок",
@@ -1240,6 +1299,11 @@
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:", "device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
"device.hue_gradient_mode": "Распределить по сегментам:",
"device.hue_gradient_mode.hint": "Распределить ленту по сегментам (каналам) градиентной ленты вместо одного усреднённого цвета на лампу. Определяется автоматически при подключении; обычные лампы не затрагиваются.",
"mqtt_source.ha_discovery": "Обнаружение Home Assistant:",
"mqtt_source.ha_discovery.hint": "Публиковать топики homeassistant/.../config, чтобы установки Home Assistant только с MQTT автоматически получали сущности автоматизаций и подключения LedGrab.",
"mqtt_source.discovery_prefix": "Префикс обнаружения:",
"device.hue.url": "Bridge IP:", "device.hue.url": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge", "device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:", "device.hue.username": "Bridge Username:",
@@ -1290,7 +1354,7 @@
"device.icon.entity.cspt": "Шаблон обработки полоски", "device.icon.entity.cspt": "Шаблон обработки полоски",
"device.icon.entity.device": "Устройство", "device.icon.entity.device": "Устройство",
"device.icon.entity.game_integration": "Игровая интеграция", "device.icon.entity.game_integration": "Игровая интеграция",
"device.icon.entity.gradient": "Градиент", "device.icon.entity.gradient": "Палитра",
"device.icon.entity.ha_light_target": "HA-светильник", "device.icon.entity.ha_light_target": "HA-светильник",
"device.icon.entity.ha_source": "Источник Home Assistant", "device.icon.entity.ha_source": "Источник Home Assistant",
"device.icon.entity.http_endpoint": "HTTP-эндпоинт", "device.icon.entity.http_endpoint": "HTTP-эндпоинт",
@@ -1389,6 +1453,8 @@
"device.lifx.url.placeholder": "192.168.1.50", "device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "Мин. интервал обновления:", "device.lifx_min_interval": "Мин. интервал обновления:",
"device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.", "device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.",
"device.lifx_per_zone": "Потоковая передача по зонам:",
"device.lifx_per_zone.hint": "Адресовать отдельные зоны (мультизона Z/Beam) или пиксели (матрица Tile/Canvas) вместо одного усреднённого цвета. Определяется автоматически при подключении; старые лампы переходят на одиночный цвет.",
"device.metrics.actual_fps": "Факт. FPS", "device.metrics.actual_fps": "Факт. FPS",
"device.metrics.current_fps": "Текущ. FPS", "device.metrics.current_fps": "Текущ. FPS",
"device.metrics.device_fps": "Частота обновления устройства", "device.metrics.device_fps": "Частота обновления устройства",
@@ -1417,6 +1483,8 @@
"device.nanoleaf.url.placeholder": "192.168.1.50", "device.nanoleaf.url.placeholder": "192.168.1.50",
"device.nanoleaf_min_interval": "Мин. интервал обновления:", "device.nanoleaf_min_interval": "Мин. интервал обновления:",
"device.nanoleaf_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 100 мс ≈ 10 Гц; накладные расходы HTTP ограничивают практический максимум ~20 Гц.", "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": "IP-адрес:",
"device.opc.url.hint": "Адрес приёмника OPC. TCP-порт по умолчанию 7890.", "device.opc.url.hint": "Адрес приёмника OPC. TCP-порт по умолчанию 7890.",
"device.opc.url.placeholder": "192.168.1.50", "device.opc.url.placeholder": "192.168.1.50",
@@ -1706,8 +1774,8 @@
"game_integration.test.timeout": "События не получены за отведённое время.", "game_integration.test.timeout": "События не получены за отведённое время.",
"game_integration.test.waiting": "Ожидание событий от игры...", "game_integration.test.waiting": "Ожидание событий от игры...",
"game_integration.updated": "Игровая интеграция обновлена", "game_integration.updated": "Игровая интеграция обновлена",
"gradient.error.delete_failed": "Не удалось удалить градиент", "gradient.error.delete_failed": "Не удалось удалить палитру",
"gradient.error.save_failed": "Не удалось сохранить градиент", "gradient.error.save_failed": "Не удалось сохранить палитру",
"graph.action.connect": "Соединить", "graph.action.connect": "Соединить",
"graph.action.disconnect": "Отсоединить", "graph.action.disconnect": "Отсоединить",
"graph.action.move": "Переместить узел", "graph.action.move": "Переместить узел",
@@ -2403,7 +2471,7 @@
"settings.section.file": "Файл", "settings.section.file": "Файл",
"settings.section.filtering": "Фильтрация", "settings.section.filtering": "Фильтрация",
"settings.section.filters": "Фильтры", "settings.section.filters": "Фильтры",
"settings.section.gradient": "Градиент", "settings.section.gradient": "Палитра",
"settings.section.hardware": "Оборудование", "settings.section.hardware": "Оборудование",
"settings.section.history": "История", "settings.section.history": "История",
"settings.section.identity": "Идентификация", "settings.section.identity": "Идентификация",
@@ -2419,6 +2487,7 @@
"settings.section.notif_permission": "Разрешение ОС", "settings.section.notif_permission": "Разрешение ОС",
"settings.section.offsets": "Смещения", "settings.section.offsets": "Смещения",
"settings.section.output": "Вывод", "settings.section.output": "Вывод",
"settings.section.power": "Питание",
"settings.section.preview": "Превью", "settings.section.preview": "Превью",
"settings.section.protocol": "Протокол", "settings.section.protocol": "Протокол",
"settings.section.provider": "Провайдер", "settings.section.provider": "Провайдер",
+76 -8
View File
@@ -41,6 +41,8 @@
"activity_log.filter.title": "过滤", "activity_log.filter.title": "过滤",
"activity_log.filter.until": "至", "activity_log.filter.until": "至",
"activity_log.live": "实时", "activity_log.live": "实时",
"z2m_light.bulbs.one": "{count} 个灯泡",
"z2m_light.bulbs.other": "{count} 个灯泡",
"activity_log.load_more": "加载更多", "activity_log.load_more": "加载更多",
"activity_log.loading": "正在加载活动日志…", "activity_log.loading": "正在加载活动日志…",
"activity_log.n_entries": "{n} 条记录", "activity_log.n_entries": "{n} 条记录",
@@ -75,6 +77,8 @@
"activity_log.msg.audit_log.disabled": "活动记录已禁用", "activity_log.msg.audit_log.disabled": "活动记录已禁用",
"activity_log.msg.automation.activated": "自动化 '{name}' 已激活", "activity_log.msg.automation.activated": "自动化 '{name}' 已激活",
"activity_log.msg.automation.deactivated": "自动化 '{name}' 已停用", "activity_log.msg.automation.deactivated": "自动化 '{name}' 已停用",
"activity_log.msg.automation.webhook_fired": "已为 '{name}' 发送 webhook",
"activity_log.msg.automation.triggered": "已手动触发自动化 '{name}'",
"activity_log.msg.server.shutting_down": "服务器正在关闭", "activity_log.msg.server.shutting_down": "服务器正在关闭",
"activity_log.msg.server.restarting": "已请求服务器重启", "activity_log.msg.server.restarting": "已请求服务器重启",
"activity_log.msg.server.shutdown_requested": "已请求服务器关闭", "activity_log.msg.server.shutdown_requested": "已请求服务器关闭",
@@ -96,7 +100,7 @@
"activity_log.entity_type.scene_playlist": "场景播放列表", "activity_log.entity_type.scene_playlist": "场景播放列表",
"activity_log.entity_type.sync_clock": "同步时钟", "activity_log.entity_type.sync_clock": "同步时钟",
"activity_log.entity_type.template": "模板", "activity_log.entity_type.template": "模板",
"activity_log.entity_type.gradient": "渐变", "activity_log.entity_type.gradient": "调色板",
"activity_log.entity_type.cspt": "处理模板", "activity_log.entity_type.cspt": "处理模板",
"activity_log.entity_type.audio_template": "音频模板", "activity_log.entity_type.audio_template": "音频模板",
"activity_log.entity_type.audio_processing_template": "音频处理模板", "activity_log.entity_type.audio_processing_template": "音频处理模板",
@@ -284,8 +288,8 @@
"auth.message": "请输入 API 密钥以进行身份验证并访问 LED Grab。", "auth.message": "请输入 API 密钥以进行身份验证并访问 LED Grab。",
"auth.placeholder": "输入您的 API 密钥...", "auth.placeholder": "输入您的 API 密钥...",
"auth.please_login": "请先登录", "auth.please_login": "请先登录",
"auth.prompt_enter": "Enter your API key:", "auth.prompt_enter": "请输入您的 API 密钥:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", "auth.prompt_update": "已设置 API 密钥。输入新密钥以更新,或留空以移除:",
"auth.session_expired": "会话已过期或 API 密钥无效,请重新登录。", "auth.session_expired": "会话已过期或 API 密钥无效,请重新登录。",
"auth.success": "登录成功!", "auth.success": "登录成功!",
"auth.title": "登录 LED Grab", "auth.title": "登录 LED Grab",
@@ -336,6 +340,7 @@
"automation.disabled": "自动化已禁用", "automation.disabled": "自动化已禁用",
"automation.enabled": "自动化已启用", "automation.enabled": "自动化已启用",
"automations.action.disable": "禁用", "automations.action.disable": "禁用",
"automations.action.trigger": "触发",
"automations.add": "添加自动化", "automations.add": "添加自动化",
"automations.created": "自动化已创建", "automations.created": "自动化已创建",
"automations.deactivation_mode": "停用方式:", "automations.deactivation_mode": "停用方式:",
@@ -360,6 +365,7 @@
"automations.error.name_required": "名称为必填项", "automations.error.name_required": "名称为必填项",
"automations.error.save_failed": "保存自动化失败", "automations.error.save_failed": "保存自动化失败",
"automations.error.toggle_failed": "切换自动化失败", "automations.error.toggle_failed": "切换自动化失败",
"automations.error.trigger_failed": "触发自动化失败",
"automations.last_activated": "上次激活", "automations.last_activated": "上次激活",
"automations.logic.all": "全部", "automations.logic.all": "全部",
"automations.logic.and": " 与 ", "automations.logic.and": " 与 ",
@@ -417,6 +423,9 @@
"automations.rule.http_poll.value": "值", "automations.rule.http_poll.value": "值",
"automations.rule.http_poll.value.placeholder": "playing", "automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.value_source": "HTTP 值源", "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": "MQTT",
"automations.rule.mqtt.desc": "MQTT 消息", "automations.rule.mqtt.desc": "MQTT 消息",
"automations.rule.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活", "automations.rule.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
@@ -437,6 +446,18 @@
"automations.rule.system_idle.when_active.desc": "当用户正在使用系统时触发", "automations.rule.system_idle.when_active.desc": "当用户正在使用系统时触发",
"automations.rule.system_idle.when_idle": "空闲时", "automations.rule.system_idle.when_idle": "空闲时",
"automations.rule.system_idle.when_idle.desc": "当用户空闲时间超过超时阈值后触发", "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": "时段",
"automations.rule.time_of_day.days": "生效日期", "automations.rule.time_of_day.days": "生效日期",
"automations.rule.time_of_day.days_hint": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。", "automations.rule.time_of_day.days_hint": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。",
@@ -470,11 +491,27 @@
"automations.scene.search_placeholder": "搜索场景...", "automations.scene.search_placeholder": "搜索场景...",
"automations.section.action": "动作", "automations.section.action": "动作",
"automations.section.deactivation": "停用", "automations.section.deactivation": "停用",
"automations.section.action": "Webhook",
"automations.action.webhook": "Webhook",
"automations.action.webhook_url": "Webhook URL",
"automations.action.webhook_url.hint": "可选。当此自动化触发时向 Discord / IFTTT / Zapier / Node-RED 的 URL 发送 POST。允许局域网地址;回环和云元数据被阻止。留空则不发送 webhook。",
"automations.action.method": "方法:",
"automations.action.fire_on": "触发时机:",
"automations.action.fire_on.activate": "激活时",
"automations.action.fire_on.deactivate": "停用时",
"automations.action.fire_on.both": "两者",
"automations.action.body_template": "正文模板:",
"automations.action.body_template.hint": "POST/PUT 的 JSON 正文。令牌:{{automation_name}}、{{automation_id}}、{{event}}、{{timestamp}}。",
"automations.error.invalid_webhook_url": "无效或被阻止的 webhook URL。",
"automations.section.triggers": "触发器", "automations.section.triggers": "触发器",
"automations.status.active": "活动", "automations.status.active": "活动",
"automations.status.disabled": "已禁用", "automations.status.disabled": "已禁用",
"automations.status.inactive": "非活动", "automations.status.inactive": "非活动",
"automations.title": "自动化", "automations.title": "自动化",
"automations.trigger.partial": "触发时出现错误",
"automations.trigger.skipped": "条件不满足 — 未触发自动化",
"automations.trigger.tooltip": "立即运行此自动化(仍会检查其规则)",
"automations.triggered": "已触发自动化",
"automations.updated": "自动化已更新", "automations.updated": "自动化已更新",
"bg.anim.toggle": "切换动态背景", "bg.anim.toggle": "切换动态背景",
"bindable.none": "无(静态值)", "bindable.none": "无(静态值)",
@@ -514,6 +551,10 @@
"calibration.advanced.switch_to_simple": "切换到简单模式", "calibration.advanced.switch_to_simple": "切换到简单模式",
"calibration.advanced.title": "高级校准", "calibration.advanced.title": "高级校准",
"calibration.border_width": "边框(像素):", "calibration.border_width": "边框(像素):",
"calibration.linear_blend": "线性光混合",
"calibration.linear_blend.hint": "在线性光空间中对边框像素求平均,以获得感知正确、更明亮的颜色混合。",
"calibration.dither": "抖动",
"calibration.dither.hint": "时空抖动可减少平滑渐变上可见的色带。",
"calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100", "calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100",
"calibration.button.cancel": "取消", "calibration.button.cancel": "取消",
"calibration.button.save": "保存", "calibration.button.save": "保存",
@@ -726,6 +767,14 @@
"color_strip.effect.meteor": "流星", "color_strip.effect.meteor": "流星",
"color_strip.effect.meteor.desc": "明亮头部沿灯带移动,带指数衰减的尾迹", "color_strip.effect.meteor.desc": "明亮头部沿灯带移动,带指数衰减的尾迹",
"color_strip.effect.mirror": "镜像:", "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.mirror.hint": "反弹模式 — 流星在灯带末端反转方向而不是循环。",
"color_strip.effect.noise": "噪声", "color_strip.effect.noise": "噪声",
"color_strip.effect.noise.desc": "滚动的分形值噪声映射到调色板", "color_strip.effect.noise.desc": "滚动的分形值噪声映射到调色板",
@@ -758,7 +807,7 @@
"color_strip.gamma.hint": "伽马校正(1=无,\u003c1=更亮的中间调,\u003e1=更暗的中间调)", "color_strip.gamma.hint": "伽马校正(1=无,\u003c1=更亮的中间调,\u003e1=更暗的中间调)",
"color_strip.gradient.add_stop": "+ 添加色标", "color_strip.gradient.add_stop": "+ 添加色标",
"color_strip.gradient.bidir.hint": "在此色标右侧添加第二种颜色以在渐变中创建硬边。", "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.position": "位置(0.0-1.0",
"color_strip.gradient.preset": "预设:", "color_strip.gradient.preset": "预设:",
"color_strip.gradient.preset.apply": "应用", "color_strip.gradient.preset.apply": "应用",
@@ -782,6 +831,15 @@
"color_strip.gradient.preset.warm": "暖色", "color_strip.gradient.preset.warm": "暖色",
"color_strip.gradient.preview": "渐变:", "color_strip.gradient.preview": "渐变:",
"color_strip.gradient.preview.hint": "可视预览。点击下方标记轨道添加色标。拖动标记重新定位。", "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": "色标:",
"color_strip.gradient.stops.hint": "每个色标在相对位置定义一种颜色(0.0 = 起始,1.0 = 结束)。↔ 按钮添加右侧颜色以在该色标处创建硬边。", "color_strip.gradient.stops.hint": "每个色标在相对位置定义一种颜色(0.0 = 起始,1.0 = 结束)。↔ 按钮添加右侧颜色以在该色标处创建硬边。",
"color_strip.gradient.stops_count": "个色标", "color_strip.gradient.stops_count": "个色标",
@@ -1238,6 +1296,11 @@
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:", "device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
"device.hue_gradient_mode": "跨分段映射:",
"device.hue_gradient_mode.hint": "将灯带分布到渐变灯带的各分段(通道),而不是每个灯具一个平均色。连接时自动检测;普通灯泡不受影响。",
"mqtt_source.ha_discovery": "Home Assistant 发现:",
"mqtt_source.ha_discovery.hint": "发布 homeassistant/.../config 主题,使仅使用 MQTT 的 Home Assistant 安装自动获得 LedGrab 的自动化和连接实体。",
"mqtt_source.discovery_prefix": "发现前缀:",
"device.hue.url": "Bridge IP:", "device.hue.url": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge", "device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:", "device.hue.username": "Bridge Username:",
@@ -1288,7 +1351,7 @@
"device.icon.entity.cspt": "色带处理模板", "device.icon.entity.cspt": "色带处理模板",
"device.icon.entity.device": "设备", "device.icon.entity.device": "设备",
"device.icon.entity.game_integration": "游戏集成", "device.icon.entity.game_integration": "游戏集成",
"device.icon.entity.gradient": "渐变", "device.icon.entity.gradient": "调色板",
"device.icon.entity.ha_light_target": "HA 灯目标", "device.icon.entity.ha_light_target": "HA 灯目标",
"device.icon.entity.ha_source": "Home Assistant 源", "device.icon.entity.ha_source": "Home Assistant 源",
"device.icon.entity.http_endpoint": "HTTP 端点", "device.icon.entity.http_endpoint": "HTTP 端点",
@@ -1387,6 +1450,8 @@
"device.lifx.url.placeholder": "192.168.1.50", "device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "最小更新间隔:", "device.lifx_min_interval": "最小更新间隔:",
"device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。", "device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。",
"device.lifx_per_zone": "逐区流式传输:",
"device.lifx_per_zone.hint": "单独寻址各个分区(Z/Beam 多区)或像素(Tile/Canvas 矩阵),而不是单一平均色。连接时自动检测;较旧的灯具回退为单色。",
"device.metrics.actual_fps": "实际 FPS", "device.metrics.actual_fps": "实际 FPS",
"device.metrics.current_fps": "当前 FPS", "device.metrics.current_fps": "当前 FPS",
"device.metrics.device_fps": "设备刷新率", "device.metrics.device_fps": "设备刷新率",
@@ -1415,6 +1480,8 @@
"device.nanoleaf.url.placeholder": "192.168.1.50", "device.nanoleaf.url.placeholder": "192.168.1.50",
"device.nanoleaf_min_interval": "最小更新间隔:", "device.nanoleaf_min_interval": "最小更新间隔:",
"device.nanoleaf_min_interval.hint": "客户端命令速率限制(毫秒)。默认 100 毫秒 ≈ 10 Hz;HTTP 请求开销将实际上限限制在约 20 Hz。", "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": "IP 地址:",
"device.opc.url.hint": "OPC 接收器地址。TCP 端口默认为 7890。", "device.opc.url.hint": "OPC 接收器地址。TCP 端口默认为 7890。",
"device.opc.url.placeholder": "192.168.1.50", "device.opc.url.placeholder": "192.168.1.50",
@@ -1704,8 +1771,8 @@
"game_integration.test.timeout": "在超时期间内未收到事件。", "game_integration.test.timeout": "在超时期间内未收到事件。",
"game_integration.test.waiting": "等待游戏事件...", "game_integration.test.waiting": "等待游戏事件...",
"game_integration.updated": "游戏集成已更新", "game_integration.updated": "游戏集成已更新",
"gradient.error.delete_failed": "删除渐变失败", "gradient.error.delete_failed": "删除调色板失败",
"gradient.error.save_failed": "保存渐变失败", "gradient.error.save_failed": "保存调色板失败",
"graph.action.connect": "连接", "graph.action.connect": "连接",
"graph.action.disconnect": "断开连接", "graph.action.disconnect": "断开连接",
"graph.action.move": "移动节点", "graph.action.move": "移动节点",
@@ -2397,7 +2464,7 @@
"settings.section.file": "文件", "settings.section.file": "文件",
"settings.section.filtering": "过滤", "settings.section.filtering": "过滤",
"settings.section.filters": "过滤器", "settings.section.filters": "过滤器",
"settings.section.gradient": "渐变", "settings.section.gradient": "调色板",
"settings.section.hardware": "硬件", "settings.section.hardware": "硬件",
"settings.section.history": "历史", "settings.section.history": "历史",
"settings.section.identity": "标识", "settings.section.identity": "标识",
@@ -2413,6 +2480,7 @@
"settings.section.notif_permission": "系统权限", "settings.section.notif_permission": "系统权限",
"settings.section.offsets": "偏移", "settings.section.offsets": "偏移",
"settings.section.output": "输出", "settings.section.output": "输出",
"settings.section.power": "电源",
"settings.section.preview": "预览", "settings.section.preview": "预览",
"settings.section.protocol": "协议", "settings.section.protocol": "协议",
"settings.section.provider": "提供商", "settings.section.provider": "提供商",
@@ -20,7 +20,7 @@ Design notes
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime, timezone
from typing import Iterator from typing import Iterator
from ledgrab.storage.activity_log import ActivityLogEntry, ActivityLogFilters from ledgrab.storage.activity_log import ActivityLogEntry, ActivityLogFilters
@@ -32,6 +32,21 @@ logger = get_logger(__name__)
_TABLE = "activity_log" _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( def _build_filter_clause(
filters: ActivityLogFilters, filters: ActivityLogFilters,
params: list, params: list,
@@ -77,11 +92,11 @@ def _build_filter_clause(
if filters.since is not None: if filters.since is not None:
conditions.append("ts >= ?") conditions.append("ts >= ?")
params.append(filters.since.isoformat()) params.append(_to_utc_iso(filters.since))
if filters.until is not None: if filters.until is not None:
conditions.append("ts <= ?") conditions.append("ts <= ?")
params.append(filters.until.isoformat()) params.append(_to_utc_iso(filters.until))
if filters.message_like is not None: if filters.message_like is not None:
# Escape LIKE special characters in the user-supplied substring so that # Escape LIKE special characters in the user-supplied substring so that
@@ -149,6 +164,42 @@ class ActivityLogRepository:
# -- Read ---------------------------------------------------------------- # -- 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( def query(
self, self,
filters: ActivityLogFilters, filters: ActivityLogFilters,
@@ -173,28 +224,10 @@ class ActivityLogRepository:
limit: limit:
Maximum number of entries to return. Maximum number of entries to return.
""" """
params: list = [] return [
keyset = "seq < ?" if before_seq is not None else None entry
if before_seq is not None: for _seq, entry in self.query_with_seq(filters, before_seq=before_seq, limit=limit)
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)]
def count(self, filters: ActivityLogFilters | None = None) -> int: def count(self, filters: ActivityLogFilters | None = None) -> int:
"""Return the number of entries matching *filters* (or all entries).""" """Return the number of entries matching *filters* (or all entries)."""
@@ -233,7 +266,7 @@ class ActivityLogRepository:
if before_ts is not None: if before_ts is not None:
cursor = self._db.execute( cursor = self._db.execute(
f"DELETE FROM {_TABLE} WHERE ts < ?", f"DELETE FROM {_TABLE} WHERE ts < ?",
(before_ts.isoformat(),), (_to_utc_iso(before_ts),),
) )
deleted += cursor.rowcount deleted += cursor.rowcount
@@ -321,8 +354,7 @@ class ActivityLogRepository:
) )
# Hold the lock only for the bounded fetchall; release before yielding. # 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.fetch_under_lock(sql, tuple(params))
rows = self._db._conn.execute(sql, tuple(params)).fetchall() # noqa: SLF001
if not rows: if not rows:
break break
+182
View File
@@ -102,6 +102,80 @@ class TimeOfDayRule(Rule):
) )
@dataclass
class SolarRule(Rule):
"""Activate during a window defined relative to sunrise / sunset.
The window runs from ``start_event`` (+``start_offset_minutes``) to
``end_event`` (+``end_offset_minutes``), where each event is either
``"sunrise"`` or ``"sunset"`` for the rule's location and the current day.
The default sunset sunrise is the common "active at night" case.
Offsets may be negative (before the event) or positive (after), clamped to
±1439 minutes. ``latitude``/``longitude`` are per-rule (mirrors the
daylight value source); ``timezone`` is an IANA name (empty = server local,
same as :class:`TimeOfDayRule`). ``days_of_week`` (0=Mon..6=Sun, empty =
every day) restricts which days the window is active, with the same
overnight attribution as ``TimeOfDayRule`` (a wrapped early-morning tail
belongs to the day the window started on).
"""
rule_type: str = "solar"
start_event: str = "sunset" # "sunrise" | "sunset"
start_offset_minutes: int = 0
end_event: str = "sunrise" # "sunrise" | "sunset"
end_offset_minutes: int = 0
latitude: float = 50.0
longitude: float = 0.0
days_of_week: List[int] = field(default_factory=list) # 0=Mon..6=Sun; empty=all days
timezone: str = "" # IANA tz name; empty = server local time
def to_dict(self) -> dict:
d = super().to_dict()
d["start_event"] = self.start_event
d["start_offset_minutes"] = self.start_offset_minutes
d["end_event"] = self.end_event
d["end_offset_minutes"] = self.end_offset_minutes
d["latitude"] = self.latitude
d["longitude"] = self.longitude
d["days_of_week"] = self.days_of_week
d["timezone"] = self.timezone
return d
@classmethod
def from_dict(cls, data: dict) -> "SolarRule":
def _event(key: str, default: str) -> str:
v = data.get(key, default)
return v if v in ("sunrise", "sunset") else default
def _offset(key: str) -> int:
try:
return max(-1439, min(1439, int(data.get(key, 0))))
except (TypeError, ValueError):
return 0
def _coord(key: str, lo: float, hi: float, default: float) -> float:
try:
return max(lo, min(hi, float(data.get(key, default))))
except (TypeError, ValueError):
return default
raw_days = data.get("days_of_week") or []
days = sorted(
{int(d) for d in raw_days if isinstance(d, (int, float)) and 0 <= int(d) <= 6}
)
return cls(
start_event=_event("start_event", "sunset"),
start_offset_minutes=_offset("start_offset_minutes"),
end_event=_event("end_event", "sunrise"),
end_offset_minutes=_offset("end_offset_minutes"),
latitude=_coord("latitude", -90.0, 90.0, 50.0),
longitude=_coord("longitude", -180.0, 180.0, 0.0),
days_of_week=days,
timezone=data.get("timezone", "") or "",
)
@dataclass @dataclass
class SystemIdleRule(Rule): class SystemIdleRule(Rule):
"""Activate based on system idle time (keyboard/mouse inactivity).""" """Activate based on system idle time (keyboard/mouse inactivity)."""
@@ -199,6 +273,23 @@ class StartupRule(Rule):
return cls() 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 @dataclass
class HomeAssistantRule(Rule): class HomeAssistantRule(Rule):
"""Activate based on a Home Assistant entity state.""" """Activate based on a Home Assistant entity state."""
@@ -273,11 +364,13 @@ class HTTPPollRule(Rule):
_RULE_MAP: Dict[str, Type[Rule]] = { _RULE_MAP: Dict[str, Type[Rule]] = {
"application": ApplicationRule, "application": ApplicationRule,
"time_of_day": TimeOfDayRule, "time_of_day": TimeOfDayRule,
"solar": SolarRule,
"system_idle": SystemIdleRule, "system_idle": SystemIdleRule,
"display_state": DisplayStateRule, "display_state": DisplayStateRule,
"mqtt": MQTTRule, "mqtt": MQTTRule,
"webhook": WebhookRule, "webhook": WebhookRule,
"startup": StartupRule, "startup": StartupRule,
"manual_trigger": ManualTriggerRule,
"home_assistant": HomeAssistantRule, "home_assistant": HomeAssistantRule,
"http_poll": HTTPPollRule, "http_poll": HTTPPollRule,
# Legacy: "always" maps to StartupRule for migration # Legacy: "always" maps to StartupRule for migration
@@ -285,6 +378,81 @@ _RULE_MAP: Dict[str, Type[Rule]] = {
} }
# ── Actions ──────────────────────────────────────────────────────────────
# Rules decide WHEN an automation is active; actions are extra side effects
# fired alongside scene activation (e.g. an outbound webhook to Discord /
# IFTTT / Zapier / Node-RED). Polymorphic via the same registry pattern as
# Rule, so new action types can be added without touching the engine wiring.
@dataclass
class Action:
"""Base action — polymorphic via the ``action_type`` discriminator."""
action_type: str
def to_dict(self) -> dict:
return {"action_type": self.action_type}
@classmethod
def from_dict(cls, data: dict) -> "Action":
at = data.get("action_type", "")
subcls = _ACTION_MAP.get(at)
if subcls is None:
raise ValueError(f"Unknown action type: {at}")
return subcls.from_dict(data)
@dataclass
class WebhookAction(Action):
"""POST/PUT/GET an outbound HTTP request when the automation fires.
``fire_on`` selects which transition triggers the call: ``"activate"``,
``"deactivate"``, or ``"both"``. ``body_template`` supports the tokens
``{{automation_name}}``, ``{{automation_id}}``, ``{{event}}`` and
``{{timestamp}}``, substituted server-side at fire time. The URL is
SSRF-gated (LAN allowed, loopback / cloud-metadata / link-local blocked)
at both save and fire time.
"""
action_type: str = "webhook"
webhook_url: str = ""
method: str = "POST" # POST | PUT | GET
body_template: str = ""
content_type: str = "application/json"
fire_on: str = "activate" # activate | deactivate | both
def to_dict(self) -> dict:
d = super().to_dict()
d["webhook_url"] = self.webhook_url
d["method"] = self.method
d["body_template"] = self.body_template
d["content_type"] = self.content_type
d["fire_on"] = self.fire_on
return d
@classmethod
def from_dict(cls, data: dict) -> "WebhookAction":
method = str(data.get("method", "POST")).upper()
if method not in ("POST", "PUT", "GET"):
method = "POST"
fire_on = data.get("fire_on", "activate")
if fire_on not in ("activate", "deactivate", "both"):
fire_on = "activate"
return cls(
webhook_url=data.get("webhook_url", ""),
method=method,
body_template=data.get("body_template", ""),
content_type=data.get("content_type", "") or "application/json",
fire_on=fire_on,
)
_ACTION_MAP: Dict[str, Type[Action]] = {
"webhook": WebhookAction,
}
# ── Backward-compatible aliases (for imports in other modules during transition) ── # ── Backward-compatible aliases (for imports in other modules during transition) ──
Condition = Rule Condition = Rule
ApplicationCondition = ApplicationRule ApplicationCondition = ApplicationRule
@@ -313,6 +481,8 @@ class Automation:
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
# Outbound side-effects fired alongside scene activation/deactivation.
actions: List[Action] = field(default_factory=list)
# Custom card icon (frontend display only) # Custom card icon (frontend display only)
icon: str = "" icon: str = ""
icon_color: str = "" icon_color: str = ""
@@ -348,6 +518,10 @@ class Automation:
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
# Only persist actions when present — keeps existing rows byte-identical
# and tolerates loading automations saved before actions existed.
if self.actions:
d["actions"] = [a.to_dict() for a in self.actions]
if self.icon: if self.icon:
d["icon"] = self.icon d["icon"] = self.icon
if self.icon_color: if self.icon_color:
@@ -370,6 +544,13 @@ class Automation:
except ValueError as e: except ValueError as e:
logger.warning("Skipping unknown rule type on load: %s", e) logger.warning("Skipping unknown rule type on load: %s", e)
actions: List[Action] = []
for a_data in data.get("actions") or []:
try:
actions.append(Action.from_dict(a_data))
except ValueError as e:
logger.warning("Skipping unknown action type on load: %s", e)
return cls( return cls(
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
@@ -380,6 +561,7 @@ class Automation:
deactivation_mode=data.get("deactivation_mode", "none"), deactivation_mode=data.get("deactivation_mode", "none"),
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"), deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
tags=data.get("tags", []), tags=data.get("tags", []),
actions=actions,
icon=data.get("icon", ""), icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""), icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat( created_at=datetime.fromisoformat(
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List from typing import List
from ledgrab.storage.automation import Automation, Rule from ledgrab.storage.automation import Action, Automation, Rule
from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.base_sqlite_store import BaseSqliteStore
from ledgrab.storage.database import Database from ledgrab.storage.database import Database
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
@@ -34,6 +34,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
deactivation_mode: str = "none", deactivation_mode: str = "none",
deactivation_scene_preset_id: str | None = None, deactivation_scene_preset_id: str | None = None,
tags: List[str] | None = None, tags: List[str] | None = None,
actions: List[Action] | None = None,
icon: str | None = None, icon: str | None = None,
icon_color: str | None = None, icon_color: str | None = None,
# Legacy parameter aliases # Legacy parameter aliases
@@ -65,6 +66,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
created_at=now, created_at=now,
updated_at=now, updated_at=now,
tags=tags or [], tags=tags or [],
actions=actions or [],
icon=icon or "", icon=icon or "",
icon_color=icon_color or "", icon_color=icon_color or "",
) )
@@ -85,6 +87,7 @@ class AutomationStore(BaseSqliteStore[Automation]):
deactivation_mode: str | None = None, deactivation_mode: str | None = None,
deactivation_scene_preset_id: str = "__unset__", deactivation_scene_preset_id: str = "__unset__",
tags: List[str] | None = None, tags: List[str] | None = None,
actions: List[Action] | None = None,
icon: str | None = None, icon: str | None = None,
icon_color: str | None = None, icon_color: str | None = None,
# Legacy parameter aliases # Legacy parameter aliases
@@ -118,6 +121,8 @@ class AutomationStore(BaseSqliteStore[Automation]):
) )
if tags is not None: if tags is not None:
automation.tags = tags automation.tags = tags
if actions is not None:
automation.actions = actions
if icon is not None: if icon is not None:
automation.icon = icon or "" automation.icon = icon or ""
if icon_color is not None: if icon_color is not None:
@@ -544,6 +544,12 @@ class EffectColorStripSource(ColorStripSource):
scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
mirror: bool = False # bounce mode (meteor/comet) mirror: bool = False # bounce mode (meteor/comet)
custom_palette: list | None = None # legacy [[pos, R, G, B], ...] custom palette stops 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: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
@@ -555,6 +561,10 @@ class EffectColorStripSource(ColorStripSource):
d["scale"] = self.scale.to_dict() d["scale"] = self.scale.to_dict()
d["mirror"] = self.mirror d["mirror"] = self.mirror
d["custom_palette"] = self.custom_palette 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 return d
@classmethod @classmethod
@@ -571,6 +581,10 @@ class EffectColorStripSource(ColorStripSource):
scale=BindableFloat.from_raw(data.get("scale"), default=1.0), scale=BindableFloat.from_raw(data.get("scale"), default=1.0),
mirror=bool(data.get("mirror", False)), mirror=bool(data.get("mirror", False)),
custom_palette=data.get("custom_palette"), 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 @classmethod
@@ -593,6 +607,10 @@ class EffectColorStripSource(ColorStripSource):
scale=None, scale=None,
mirror=False, mirror=False,
custom_palette=None, custom_palette=None,
audio_reactive=False,
reactive_audio_source_id="",
reactive_mode="brightness",
reactive_intensity=None,
**_kwargs, **_kwargs,
): ):
return cls( return cls(
@@ -612,6 +630,10 @@ class EffectColorStripSource(ColorStripSource):
scale=BindableFloat.from_raw(scale, default=1.0), scale=BindableFloat.from_raw(scale, default=1.0),
mirror=bool(mirror), mirror=bool(mirror),
custom_palette=custom_palette if isinstance(custom_palette, list) else None, 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: def apply_update(self, **kwargs) -> None:
@@ -633,6 +655,16 @@ class EffectColorStripSource(ColorStripSource):
if "custom_palette" in kwargs: if "custom_palette" in kwargs:
cp = kwargs["custom_palette"] cp = kwargs["custom_palette"]
self.custom_palette = cp if isinstance(cp, list) else None 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 @dataclass
+13
View File
@@ -197,6 +197,19 @@ class Database:
self._conn.executemany(sql, params_list) self._conn.executemany(sql, params_list)
self._conn.commit() 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 @contextmanager
def transaction(self): def transaction(self):
"""Context manager for multi-statement transactions. """Context manager for multi-statement transactions.
@@ -81,12 +81,14 @@ class Device:
hue_username: str = "", hue_username: str = "",
hue_client_key: str = "", hue_client_key: str = "",
hue_entertainment_group_id: str = "", hue_entertainment_group_id: str = "",
hue_gradient_mode: bool = True,
# Yeelight fields # Yeelight fields
yeelight_min_interval_ms: int = 500, yeelight_min_interval_ms: int = 500,
# WiZ fields # WiZ fields
wiz_min_interval_ms: int = 50, wiz_min_interval_ms: int = 50,
# LIFX fields # LIFX fields
lifx_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50,
lifx_per_zone: bool = False,
# Govee fields # Govee fields
govee_min_interval_ms: int = 50, govee_min_interval_ms: int = 50,
# OPC fields # OPC fields
@@ -94,6 +96,7 @@ class Device:
# Nanoleaf fields # Nanoleaf fields
nanoleaf_token: str = "", nanoleaf_token: str = "",
nanoleaf_min_interval_ms: int = 100, nanoleaf_min_interval_ms: int = 100,
nanoleaf_per_panel: bool = False,
# SPI Direct fields # SPI Direct fields
spi_speed_hz: int = 800000, spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B", spi_led_type: str = "WS2812B",
@@ -141,13 +144,16 @@ class Device:
self.hue_username = hue_username self.hue_username = hue_username
self.hue_client_key = hue_client_key self.hue_client_key = hue_client_key
self.hue_entertainment_group_id = hue_entertainment_group_id self.hue_entertainment_group_id = hue_entertainment_group_id
self.hue_gradient_mode = hue_gradient_mode
self.yeelight_min_interval_ms = yeelight_min_interval_ms self.yeelight_min_interval_ms = yeelight_min_interval_ms
self.wiz_min_interval_ms = wiz_min_interval_ms self.wiz_min_interval_ms = wiz_min_interval_ms
self.lifx_min_interval_ms = lifx_min_interval_ms self.lifx_min_interval_ms = lifx_min_interval_ms
self.lifx_per_zone = lifx_per_zone
self.govee_min_interval_ms = govee_min_interval_ms self.govee_min_interval_ms = govee_min_interval_ms
self.opc_channel = opc_channel self.opc_channel = opc_channel
self.nanoleaf_token = nanoleaf_token self.nanoleaf_token = nanoleaf_token
self.nanoleaf_min_interval_ms = nanoleaf_min_interval_ms 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_speed_hz = spi_speed_hz
self.spi_led_type = spi_led_type self.spi_led_type = spi_led_type
self.chroma_device_type = chroma_device_type self.chroma_device_type = chroma_device_type
@@ -239,6 +245,7 @@ class Device:
hue_username=self.hue_username, hue_username=self.hue_username,
hue_client_key=self.hue_client_key, hue_client_key=self.hue_client_key,
hue_entertainment_group_id=self.hue_entertainment_group_id, hue_entertainment_group_id=self.hue_entertainment_group_id,
hue_gradient_mode=self.hue_gradient_mode,
) )
if dt == "yeelight": if dt == "yeelight":
return YeelightConfig( return YeelightConfig(
@@ -254,6 +261,7 @@ class Device:
return LIFXConfig( return LIFXConfig(
**base, **base,
lifx_min_interval_ms=self.lifx_min_interval_ms, lifx_min_interval_ms=self.lifx_min_interval_ms,
lifx_per_zone=self.lifx_per_zone,
) )
if dt == "govee": if dt == "govee":
return GoveeConfig( return GoveeConfig(
@@ -270,6 +278,7 @@ class Device:
**base, **base,
nanoleaf_token=self.nanoleaf_token, nanoleaf_token=self.nanoleaf_token,
nanoleaf_min_interval_ms=self.nanoleaf_min_interval_ms, nanoleaf_min_interval_ms=self.nanoleaf_min_interval_ms,
nanoleaf_per_panel=self.nanoleaf_per_panel,
) )
if dt == "spi": if dt == "spi":
return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type) return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
@@ -349,12 +358,17 @@ class Device:
d["hue_client_key"] = _enc(self.hue_client_key) d["hue_client_key"] = _enc(self.hue_client_key)
if self.hue_entertainment_group_id: if self.hue_entertainment_group_id:
d["hue_entertainment_group_id"] = self.hue_entertainment_group_id d["hue_entertainment_group_id"] = self.hue_entertainment_group_id
# Gradient mode defaults ON — only persist the opt-out.
if not self.hue_gradient_mode:
d["hue_gradient_mode"] = False
if self.yeelight_min_interval_ms != 500: if self.yeelight_min_interval_ms != 500:
d["yeelight_min_interval_ms"] = self.yeelight_min_interval_ms d["yeelight_min_interval_ms"] = self.yeelight_min_interval_ms
if self.wiz_min_interval_ms != 50: if self.wiz_min_interval_ms != 50:
d["wiz_min_interval_ms"] = self.wiz_min_interval_ms d["wiz_min_interval_ms"] = self.wiz_min_interval_ms
if self.lifx_min_interval_ms != 50: if self.lifx_min_interval_ms != 50:
d["lifx_min_interval_ms"] = self.lifx_min_interval_ms d["lifx_min_interval_ms"] = self.lifx_min_interval_ms
if self.lifx_per_zone:
d["lifx_per_zone"] = True
if self.govee_min_interval_ms != 50: if self.govee_min_interval_ms != 50:
d["govee_min_interval_ms"] = self.govee_min_interval_ms d["govee_min_interval_ms"] = self.govee_min_interval_ms
if self.opc_channel: if self.opc_channel:
@@ -364,6 +378,8 @@ class Device:
d["nanoleaf_token"] = _enc(self.nanoleaf_token) d["nanoleaf_token"] = _enc(self.nanoleaf_token)
if self.nanoleaf_min_interval_ms != 100: if self.nanoleaf_min_interval_ms != 100:
d["nanoleaf_min_interval_ms"] = self.nanoleaf_min_interval_ms 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: if self.spi_speed_hz != 800000:
d["spi_speed_hz"] = self.spi_speed_hz d["spi_speed_hz"] = self.spi_speed_hz
if self.spi_led_type != "WS2812B": if self.spi_led_type != "WS2812B":
@@ -419,13 +435,16 @@ class Device:
hue_username=_dec(data.get("hue_username", "")), hue_username=_dec(data.get("hue_username", "")),
hue_client_key=_dec(data.get("hue_client_key", "")), hue_client_key=_dec(data.get("hue_client_key", "")),
hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""), hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""),
hue_gradient_mode=bool(data.get("hue_gradient_mode", True)),
yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500), yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500),
wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50), wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50),
lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50), lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50),
lifx_per_zone=bool(data.get("lifx_per_zone", False)),
govee_min_interval_ms=data.get("govee_min_interval_ms", 50), govee_min_interval_ms=data.get("govee_min_interval_ms", 50),
opc_channel=data.get("opc_channel", 0), opc_channel=data.get("opc_channel", 0),
nanoleaf_token=_dec(data.get("nanoleaf_token", "")), nanoleaf_token=_dec(data.get("nanoleaf_token", "")),
nanoleaf_min_interval_ms=data.get("nanoleaf_min_interval_ms", 100), 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_speed_hz=data.get("spi_speed_hz", 800000),
spi_led_type=data.get("spi_led_type", "WS2812B"), spi_led_type=data.get("spi_led_type", "WS2812B"),
chroma_device_type=data.get("chroma_device_type", "chromalink"), chroma_device_type=data.get("chroma_device_type", "chromalink"),
@@ -473,13 +492,16 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
"hue_username", "hue_username",
"hue_client_key", "hue_client_key",
"hue_entertainment_group_id", "hue_entertainment_group_id",
"hue_gradient_mode",
"yeelight_min_interval_ms", "yeelight_min_interval_ms",
"wiz_min_interval_ms", "wiz_min_interval_ms",
"lifx_min_interval_ms", "lifx_min_interval_ms",
"lifx_per_zone",
"govee_min_interval_ms", "govee_min_interval_ms",
"opc_channel", "opc_channel",
"nanoleaf_token", "nanoleaf_token",
"nanoleaf_min_interval_ms", "nanoleaf_min_interval_ms",
"nanoleaf_per_panel",
"spi_speed_hz", "spi_speed_hz",
"spi_led_type", "spi_led_type",
"chroma_device_type", "chroma_device_type",
@@ -580,13 +602,16 @@ class DeviceStore(BaseSqliteStore[Device]):
hue_username: str = "", hue_username: str = "",
hue_client_key: str = "", hue_client_key: str = "",
hue_entertainment_group_id: str = "", hue_entertainment_group_id: str = "",
hue_gradient_mode: bool = True,
yeelight_min_interval_ms: int = 500, yeelight_min_interval_ms: int = 500,
wiz_min_interval_ms: int = 50, wiz_min_interval_ms: int = 50,
lifx_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50,
lifx_per_zone: bool = False,
govee_min_interval_ms: int = 50, govee_min_interval_ms: int = 50,
opc_channel: int = 0, opc_channel: int = 0,
nanoleaf_token: str = "", nanoleaf_token: str = "",
nanoleaf_min_interval_ms: int = 100, nanoleaf_min_interval_ms: int = 100,
nanoleaf_per_panel: bool = False,
spi_speed_hz: int = 800000, spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B", spi_led_type: str = "WS2812B",
chroma_device_type: str = "chromalink", chroma_device_type: str = "chromalink",
@@ -630,13 +655,16 @@ class DeviceStore(BaseSqliteStore[Device]):
hue_username=hue_username, hue_username=hue_username,
hue_client_key=hue_client_key, hue_client_key=hue_client_key,
hue_entertainment_group_id=hue_entertainment_group_id, hue_entertainment_group_id=hue_entertainment_group_id,
hue_gradient_mode=hue_gradient_mode,
yeelight_min_interval_ms=yeelight_min_interval_ms, yeelight_min_interval_ms=yeelight_min_interval_ms,
wiz_min_interval_ms=wiz_min_interval_ms, wiz_min_interval_ms=wiz_min_interval_ms,
lifx_min_interval_ms=lifx_min_interval_ms, lifx_min_interval_ms=lifx_min_interval_ms,
lifx_per_zone=lifx_per_zone,
govee_min_interval_ms=govee_min_interval_ms, govee_min_interval_ms=govee_min_interval_ms,
opc_channel=opc_channel, opc_channel=opc_channel,
nanoleaf_token=nanoleaf_token, nanoleaf_token=nanoleaf_token,
nanoleaf_min_interval_ms=nanoleaf_min_interval_ms, nanoleaf_min_interval_ms=nanoleaf_min_interval_ms,
nanoleaf_per_panel=nanoleaf_per_panel,
spi_speed_hz=spi_speed_hz, spi_speed_hz=spi_speed_hz,
spi_led_type=spi_led_type, spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type, chroma_device_type=chroma_device_type,
+18 -4
View File
@@ -10,7 +10,9 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, List 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 # Keys inside ``adapter_config`` that hold secrets and must be encrypted at
# rest (and decrypted on load). Mirrors the secret_box pattern used by # 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 Migration-safe: ``secret_box.decrypt`` returns the input unchanged when it
is not an encryption envelope, so legacy plaintext rows still load. On a is not an encryption envelope, so legacy plaintext rows still load. On a
corrupt/undecryptable envelope, fall back to dropping the value rather than corrupt/undecryptable envelope (e.g. ``data/.secret_key`` was lost/rotated,
crashing the whole store load. 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) result = dict(adapter_config)
for key in _SECRET_CONFIG_KEYS: for key in _SECRET_CONFIG_KEYS:
@@ -48,7 +56,13 @@ def _decrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
try: try:
result[key] = secret_box.decrypt(value) result[key] = secret_box.decrypt(value)
except Exception: 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). # else: legacy plaintext — leave as-is (migration-safe read path).
return result return result
+32 -7
View File
@@ -14,7 +14,9 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List 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: def _parse_common(data: dict) -> dict:
@@ -58,19 +60,36 @@ class HTTPEndpoint:
icon_color: str = "" icon_color: str = ""
def __post_init__(self) -> None: def __post_init__(self) -> None:
# Invariant: ``self.auth_token`` is always plaintext at runtime. # Invariant: ``self.auth_token`` is plaintext at runtime when the key is
# If a caller constructed this from a raw dict that still holds the # available. If a caller constructed this from a raw dict that still
# encrypted envelope, decrypt now so ``build_request_headers`` # holds the encrypted envelope, decrypt now so ``build_request_headers``
# doesn't accidentally send ``Authorization: Bearer <envelope>``. # doesn't accidentally send ``Authorization: Bearer <envelope>``.
# On decrypt failure (secret key missing/rotated) PRESERVE the envelope
# rather than blanking it — blanking would let a later write-through
# save overwrite the recoverable ciphertext with "" (silent data loss).
# A still-encrypted token is treated as absent at request-build time.
if self.auth_token and secret_box.is_encrypted(self.auth_token): if self.auth_token and secret_box.is_encrypted(self.auth_token):
try: try:
self.auth_token = secret_box.decrypt(self.auth_token) self.auth_token = secret_box.decrypt(self.auth_token)
except Exception: 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 @property
def plaintext_token(self) -> str: 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]: def build_request_headers(self) -> Dict[str, str]:
"""Compose the headers actually sent on a fetch. """Compose the headers actually sent on a fetch.
@@ -82,7 +101,13 @@ class HTTPEndpoint:
""" """
result: Dict[str, str] = dict(self.headers) result: Dict[str, str] = dict(self.headers)
already_has_auth = any(k.lower() == "authorization" for k in result) 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}" result["Authorization"] = f"Bearer {self.auth_token}"
return result return result
@@ -48,6 +48,10 @@ class MQTTSource:
password: str = "" password: str = ""
client_id: str = "ledgrab" client_id: str = "ledgrab"
base_topic: str = "ledgrab" base_topic: str = "ledgrab"
# Home Assistant MQTT auto-discovery: publish homeassistant/.../config so
# MQTT-only HA installs get LedGrab entities automatically.
publish_ha_discovery: bool = False
discovery_prefix: str = "homeassistant"
description: str | None = None description: str | None = None
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = "" icon: str = ""
@@ -66,6 +70,8 @@ class MQTTSource:
"password": stored_password, "password": stored_password,
"client_id": self.client_id, "client_id": self.client_id,
"base_topic": self.base_topic, "base_topic": self.base_topic,
"publish_ha_discovery": self.publish_ha_discovery,
"discovery_prefix": self.discovery_prefix,
"description": self.description, "description": self.description,
"tags": self.tags, "tags": self.tags,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
@@ -93,6 +99,8 @@ class MQTTSource:
password=password, password=password,
client_id=data.get("client_id", "ledgrab"), client_id=data.get("client_id", "ledgrab"),
base_topic=data.get("base_topic", "ledgrab"), base_topic=data.get("base_topic", "ledgrab"),
publish_ha_discovery=bool(data.get("publish_ha_discovery", False)),
discovery_prefix=data.get("discovery_prefix", "homeassistant") or "homeassistant",
icon=data.get("icon", ""), icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""), icon_color=data.get("icon_color", ""),
) )
@@ -70,6 +70,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password: str = "", password: str = "",
client_id: str = "ledgrab", client_id: str = "ledgrab",
base_topic: str = "ledgrab", base_topic: str = "ledgrab",
publish_ha_discovery: bool = False,
discovery_prefix: str = "homeassistant",
description: str | None = None, description: str | None = None,
tags: List[str] | None = None, tags: List[str] | None = None,
icon: str | None = None, icon: str | None = None,
@@ -94,6 +96,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password=password, password=password,
client_id=client_id, client_id=client_id,
base_topic=base_topic, base_topic=base_topic,
publish_ha_discovery=publish_ha_discovery,
discovery_prefix=discovery_prefix or "homeassistant",
description=description, description=description,
tags=tags or [], tags=tags or [],
icon=icon or "", icon=icon or "",
@@ -115,6 +119,8 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password: str | None = None, password: str | None = None,
client_id: str | None = None, client_id: str | None = None,
base_topic: str | None = None, base_topic: str | None = None,
publish_ha_discovery: bool | None = None,
discovery_prefix: str | None = None,
description: str | None = None, description: str | None = None,
tags: List[str] | None = None, tags: List[str] | None = None,
icon: str | None = None, icon: str | None = None,
@@ -136,6 +142,14 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
password=password if password is not None else existing.password, password=password if password is not None else existing.password,
client_id=client_id if client_id is not None else existing.client_id, client_id=client_id if client_id is not None else existing.client_id,
base_topic=base_topic if base_topic is not None else existing.base_topic, base_topic=base_topic if base_topic is not None else existing.base_topic,
publish_ha_discovery=(
publish_ha_discovery
if publish_ha_discovery is not None
else existing.publish_ha_discovery
),
discovery_prefix=(
discovery_prefix if discovery_prefix is not None else existing.discovery_prefix
),
description=description if description is not None else existing.description, description=description if description is not None else existing.description,
tags=tags if tags is not None else existing.tags, tags=tags if tags is not None else existing.tags,
icon=icon if icon is not None else existing.icon, icon=icon if icon is not None else existing.icon,
@@ -265,6 +265,17 @@
<small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small> <small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small>
<input type="number" id="device-lifx-min-interval" min="0" max="10000" step="10" value="50"> <input type="number" id="device-lifx-min-interval" min="0" max="10000" step="10" value="50">
</div> </div>
<div class="form-group" id="device-lifx-per-zone-group" style="display: none;">
<div class="label-row">
<label for="device-lifx-per-zone" data-i18n="device.lifx_per_zone">Per-zone streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.lifx_per_zone.hint">Address individual zones (Z/Beam multizone) or pixels (Tile/Canvas matrix) instead of one averaged colour. Auto-detected on connect; older bulbs fall back to single colour.</small>
<label class="settings-toggle">
<input type="checkbox" id="device-lifx-per-zone">
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- Govee fields --> <!-- Govee fields -->
<div class="form-group" id="device-govee-min-interval-group" style="display: none;"> <div class="form-group" id="device-govee-min-interval-group" style="display: none;">
<div class="label-row"> <div class="label-row">
@@ -283,6 +294,17 @@
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_min_interval.hint">Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.</small> <small class="input-hint" style="display:none" data-i18n="device.nanoleaf_min_interval.hint">Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.</small>
<input type="number" id="device-nanoleaf-min-interval" min="0" max="10000" step="10" value="100"> <input type="number" id="device-nanoleaf-min-interval" min="0" max="10000" step="10" value="100">
</div> </div>
<div class="form-group" id="device-nanoleaf-per-panel-group" style="display: none;">
<div class="label-row">
<label for="device-nanoleaf-per-panel" data-i18n="device.nanoleaf_per_panel">Per-panel streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_per_panel.hint">Stream each panel individually via extControl UDP instead of one averaged colour. Requires a recent controller firmware.</small>
<label class="settings-toggle">
<input type="checkbox" id="device-nanoleaf-per-panel">
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- ESP-NOW fields --> <!-- ESP-NOW fields -->
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;"> <div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
<div class="label-row"> <div class="label-row">
@@ -325,6 +347,17 @@
<small class="input-hint" style="display:none" data-i18n="device.hue.group_id.hint">Entertainment configuration ID from your Hue bridge</small> <small class="input-hint" style="display:none" data-i18n="device.hue.group_id.hint">Entertainment configuration ID from your Hue bridge</small>
<input type="text" id="device-hue-group-id" placeholder="Entertainment group ID"> <input type="text" id="device-hue-group-id" placeholder="Entertainment group ID">
</div> </div>
<div class="form-group" id="device-hue-gradient-mode-group" style="display: none;">
<div class="label-row">
<label for="device-hue-gradient-mode" data-i18n="device.hue_gradient_mode">Map across segments:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.hue_gradient_mode.hint">Spread the strip across a gradient lightstrip's segments (channels) instead of one averaged colour per light. Auto-detected on connect; plain bulbs are unaffected.</small>
<label class="settings-toggle">
<input type="checkbox" id="device-hue-gradient-mode" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- BLE LED Controller fields --> <!-- BLE LED Controller fields -->
<div class="form-group" id="device-ble-family-group" style="display: none;"> <div class="form-group" id="device-ble-family-group" style="display: none;">
<div class="label-row"> <div class="label-row">
@@ -132,6 +132,48 @@
</div> </div>
</section> </section>
<section class="ds-section" data-ds-key="action" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="automations.section.action">Webhook</span>
<span class="ds-section-index" aria-hidden="true">05</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="automation-action-webhook-url" data-i18n="automations.action.webhook_url">Webhook URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.action.webhook_url.hint">Optional. POST to a Discord / IFTTT / Zapier / Node-RED URL when this automation fires. LAN addresses are allowed; loopback and cloud-metadata are blocked. Leave empty for no webhook.</small>
<input type="text" id="automation-action-webhook-url" placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="form-group" id="automation-action-fields" style="display:none">
<div class="label-row">
<label for="automation-action-method" data-i18n="automations.action.method">Method:</label>
</div>
<select id="automation-action-method">
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="GET">GET</option>
</select>
<div class="label-row" style="margin-top:0.5rem">
<label for="automation-action-fire-on" data-i18n="automations.action.fire_on">Fire on:</label>
</div>
<select id="automation-action-fire-on">
<option value="activate" data-i18n="automations.action.fire_on.activate">When activated</option>
<option value="deactivate" data-i18n="automations.action.fire_on.deactivate">When deactivated</option>
<option value="both" data-i18n="automations.action.fire_on.both">Both</option>
</select>
<div class="label-row" style="margin-top:0.5rem">
<label for="automation-action-body" data-i18n="automations.action.body_template">Body template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.action.body_template.hint">JSON body for POST/PUT. Tokens: {{automation_name}}, {{automation_id}}, {{event}}, {{timestamp}}.</small>
<textarea id="automation-action-body" rows="3" placeholder='{"content": "LedGrab: {{automation_name}} {{event}}"}'></textarea>
</div>
</div>
</section>
<div id="automation-editor-error" class="error-message" style="display: none;"></div> <div id="automation-editor-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </div>
@@ -194,6 +194,22 @@
<input type="number" id="cal-roi-height" min="1" max="100" value="100"> <input type="number" id="cal-roi-height" min="1" max="100" value="100">
</div> </div>
</div> </div>
<div class="calibration-linear-row">
<label class="settings-toggle">
<input type="checkbox" id="cal-linear-blend">
<span class="settings-toggle-slider"></span>
</label>
<span data-i18n="calibration.linear_blend">Linear-light blending</span>
<small class="input-hint" data-i18n="calibration.linear_blend.hint">Average border pixels in linear light for perceptually correct, brighter colour mixing.</small>
</div>
<div class="calibration-linear-row">
<label class="settings-toggle">
<input type="checkbox" id="cal-dither">
<span class="settings-toggle-slider"></span>
</label>
<span data-i18n="calibration.dither">Dithering</span>
<small class="input-hint" data-i18n="calibration.dither.hint">Spatio-temporal dithering reduces visible banding on smooth gradients.</small>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -114,10 +114,10 @@
<div id="css-editor-gradient-section" style="display:none"> <div id="css-editor-gradient-section" style="display:none">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-gradient-preset" data-i18n="color_strip.gradient.select">Gradient:</label> <label for="css-editor-gradient-preset" data-i18n="color_strip.gradient.select">Palette:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.select.hint">Select a gradient from the library. Create and edit gradients in the Gradients tab.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.gradient.select.hint">Select a palette from the library. Create and edit palettes in the Palettes tab.</small>
<select id="css-editor-gradient-preset"> <select id="css-editor-gradient-preset">
</select> </select>
</div> </div>
@@ -214,6 +214,37 @@
<span class="settings-toggle-slider"></span> <span class="settings-toggle-slider"></span>
</label> </label>
</div> </div>
<!-- Audio reactivity -->
<div class="form-group">
<div class="label-row">
<label for="css-editor-effect-audio-reactive" data-i18n="color_strip.effect.reactive">Audio reactive:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.reactive.hint">Modulate this effect's brightness and/or saturation with live audio loudness.</small>
<label class="settings-toggle">
<input type="checkbox" id="css-editor-effect-audio-reactive" onchange="onEffectReactiveToggle()">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div id="css-editor-effect-reactive-group" style="display:none">
<div class="form-group">
<label for="css-editor-effect-reactive-source" data-i18n="color_strip.effect.reactive.source">Audio source:</label>
<select id="css-editor-effect-reactive-source"></select>
</div>
<div class="form-group">
<label for="css-editor-effect-reactive-mode" data-i18n="color_strip.effect.reactive.mode">Modulate:</label>
<select id="css-editor-effect-reactive-mode">
<option value="brightness" data-i18n="color_strip.effect.reactive.mode.brightness">Brightness</option>
<option value="saturation" data-i18n="color_strip.effect.reactive.mode.saturation">Saturation</option>
<option value="both" data-i18n="color_strip.effect.reactive.mode.both">Both</option>
</select>
</div>
<div class="form-group">
<label for="css-editor-effect-reactive-intensity" data-i18n="color_strip.effect.reactive.intensity">Strength:</label>
<input type="range" id="css-editor-effect-reactive-intensity" min="0" max="1" step="0.05" value="0.7">
</div>
</div>
</div> </div>
<!-- Composite-specific fields --> <!-- Composite-specific fields -->
@@ -294,6 +294,17 @@
<small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small> <small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small>
<input type="number" id="settings-lifx-min-interval" min="0" max="10000" step="10" value="50"> <input type="number" id="settings-lifx-min-interval" min="0" max="10000" step="10" value="50">
</div> </div>
<div class="form-group" id="settings-lifx-per-zone-group" style="display: none;">
<div class="label-row">
<label for="settings-lifx-per-zone" data-i18n="device.lifx_per_zone">Per-zone streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.lifx_per_zone.hint">Address individual zones (Z/Beam multizone) or pixels (Tile/Canvas matrix) instead of one averaged colour. Auto-detected on connect; older bulbs fall back to single colour.</small>
<label class="settings-toggle">
<input type="checkbox" id="settings-lifx-per-zone">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group" id="settings-govee-min-interval-group" style="display: none;"> <div class="form-group" id="settings-govee-min-interval-group" style="display: none;">
<div class="label-row"> <div class="label-row">
@@ -312,6 +323,17 @@
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_min_interval.hint">Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.</small> <small class="input-hint" style="display:none" data-i18n="device.nanoleaf_min_interval.hint">Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.</small>
<input type="number" id="settings-nanoleaf-min-interval" min="0" max="10000" step="10" value="100"> <input type="number" id="settings-nanoleaf-min-interval" min="0" max="10000" step="10" value="100">
</div> </div>
<div class="form-group" id="settings-nanoleaf-per-panel-group" style="display: none;">
<div class="label-row">
<label for="settings-nanoleaf-per-panel" data-i18n="device.nanoleaf_per_panel">Per-panel streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_per_panel.hint">Stream each panel individually via extControl UDP instead of one averaged colour. Requires a recent controller firmware.</small>
<label class="settings-toggle">
<input type="checkbox" id="settings-nanoleaf-per-panel">
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- Read-only pairing indicator. We never show the <!-- Read-only pairing indicator. We never show the
token value itself (it's encrypted at rest and token value itself (it's encrypted at rest and
@@ -2,7 +2,7 @@
<div id="gradient-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gradient-editor-title"> <div id="gradient-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gradient-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="gradient-editor-title" data-i18n="gradient.add">Add Gradient</h2> <h2 id="gradient-editor-title" data-i18n="gradient.add">Add Palette</h2>
<button class="modal-close-btn" onclick="closeGradientEditor()" data-i18n-aria-label="aria.close">&times;</button> <button class="modal-close-btn" onclick="closeGradientEditor()" data-i18n-aria-label="aria.close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -22,7 +22,7 @@
<label for="gradient-editor-name" data-i18n="gradient.name">Name:</label> <label for="gradient-editor-name" data-i18n="gradient.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="gradient.name.hint">A descriptive name for this gradient.</small> <small class="input-hint" style="display:none" data-i18n="gradient.name.hint">A descriptive name for this palette.</small>
<input type="text" id="gradient-editor-name" required> <input type="text" id="gradient-editor-name" required>
<div id="gradient-editor-tags-container"></div> <div id="gradient-editor-tags-container"></div>
</div> </div>
@@ -32,7 +32,7 @@
<label for="gradient-editor-description" data-i18n="gradient.description">Description:</label> <label for="gradient-editor-description" data-i18n="gradient.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="gradient.description.hint">Optional description for this gradient.</small> <small class="input-hint" style="display:none" data-i18n="gradient.description.hint">Optional description for this palette.</small>
<input type="text" id="gradient-editor-description" maxlength="500"> <input type="text" id="gradient-editor-description" maxlength="500">
</div> </div>
</div> </div>
@@ -42,7 +42,7 @@
<section class="ds-section" data-ds-key="gradient" data-ch="magenta"> <section class="ds-section" data-ds-key="gradient" data-ch="magenta">
<div class="ds-section-header"> <div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span> <span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.gradient">Gradient</span> <span class="ds-section-title" data-i18n="settings.section.gradient">Palette</span>
<span class="ds-section-index" aria-hidden="true">02</span> <span class="ds-section-index" aria-hidden="true">02</span>
</div> </div>
<div class="ds-section-body"> <div class="ds-section-body">
@@ -58,6 +58,25 @@
</div> </div>
</div> </div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.gradient.harmony">Color harmony:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.harmony.hint">Pick a base color, then generate a harmonious set of stops from classic color-theory relationships.</small>
<div class="gradient-harmony-row">
<input type="color" id="ge-gradient-harmony-base" value="#3366ff" data-i18n-title="color_strip.gradient.harmony.base" title="Base color">
<div id="ge-gradient-harmony-types" class="gradient-harmony-types">
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="complementary" data-i18n="color_strip.gradient.harmony.complementary">Complementary</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="analogous" data-i18n="color_strip.gradient.harmony.analogous">Analogous</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="triadic" data-i18n="color_strip.gradient.harmony.triadic">Triadic</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="split_complementary" data-i18n="color_strip.gradient.harmony.split_complementary">Split</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="tetradic" data-i18n="color_strip.gradient.harmony.tetradic">Tetradic</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="monochromatic" data-i18n="color_strip.gradient.harmony.monochromatic">Mono</button>
</div>
</div>
</div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label data-i18n="color_strip.gradient.stops">Color Stops:</label> <label data-i18n="color_strip.gradient.stops">Color Stops:</label>
@@ -107,6 +107,24 @@
<small class="input-hint" style="display:none" data-i18n="mqtt_source.base_topic.hint">Prefix for status and state topics, e.g. ledgrab/status</small> <small class="input-hint" style="display:none" data-i18n="mqtt_source.base_topic.hint">Prefix for status and state topics, e.g. ledgrab/status</small>
<input type="text" id="mqtt-source-base-topic" value="ledgrab" placeholder="ledgrab"> <input type="text" id="mqtt-source-base-topic" value="ledgrab" placeholder="ledgrab">
</div> </div>
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-ha-discovery" data-i18n="mqtt_source.ha_discovery">Home Assistant discovery:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.ha_discovery.hint">Publish homeassistant/.../config topics so MQTT-only Home Assistant installs get LedGrab automation + connectivity entities automatically.</small>
<label class="settings-toggle">
<input type="checkbox" id="mqtt-source-ha-discovery">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group" id="mqtt-source-discovery-prefix-group" style="display:none">
<div class="label-row">
<label for="mqtt-source-discovery-prefix" data-i18n="mqtt_source.discovery_prefix">Discovery prefix:</label>
</div>
<input type="text" id="mqtt-source-discovery-prefix" value="homeassistant" placeholder="homeassistant">
</div>
</div> </div>
</section> </section>
@@ -570,7 +570,7 @@
<label for="al-settings-max-days" data-i18n="settings.activity_log.max_days.label">Max age (days)</label> <label for="al-settings-max-days" data-i18n="settings.activity_log.max_days.label">Max age (days)</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="settings.activity_log.max_days.hint">Entries older than this many days are pruned automatically. Set to 0 to disable age-based pruning (keep forever, up to the entry limit).</small> <small id="al-settings-max-days-hint" class="input-hint" style="display:none" data-i18n="settings.activity_log.max_days.hint">Entries older than this many days are pruned automatically. Set to 0 to disable age-based pruning (keep forever, up to the entry limit).</small>
<input type="number" id="al-settings-max-days" min="0" max="3650" value="365" aria-describedby="al-settings-max-days-hint"> <input type="number" id="al-settings-max-days" min="0" max="3650" value="365" aria-describedby="al-settings-max-days-hint">
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -578,7 +578,7 @@
<label for="al-settings-max-entries" data-i18n="settings.activity_log.max_entries.label">Max entries</label> <label for="al-settings-max-entries" data-i18n="settings.activity_log.max_entries.label">Max entries</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="settings.activity_log.max_entries.hint">Maximum number of entries to keep. When this limit is reached, the oldest entries are pruned. Set to 0 for no limit.</small> <small id="al-settings-max-entries-hint" class="input-hint" style="display:none" data-i18n="settings.activity_log.max_entries.hint">Maximum number of entries to keep. When this limit is reached, the oldest entries are pruned. Set to 0 for no limit.</small>
<input type="number" id="al-settings-max-entries" min="0" max="10000000" value="100000" aria-describedby="al-settings-max-entries-hint"> <input type="number" id="al-settings-max-entries" min="0" max="10000000" value="100000" aria-describedby="al-settings-max-entries-hint">
</div> </div>
</div> </div>
@@ -3,8 +3,10 @@
Identity (signal) — name + tags Identity (signal) — name + tags
Routing (cyan) — device + color strip source Routing (cyan) — device + color strip source
Output (amber) — brightness + FPS + advanced (collapsible) Output (amber) — brightness + FPS + advanced (collapsible)
Power (coral) — ABL current budget + per-LED draw
The `<details>` advanced collapse is preserved inside the Output The `<details>` advanced collapse is preserved inside the Output
section. All inner element IDs preserved. --> section; the power/ABL controls live in their own Power section.
All inner element IDs preserved. -->
<div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title"> <div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -139,27 +141,37 @@
<small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small> <small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value"> <input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
</div> </div>
<div class="form-group" id="target-editor-power-limit-group">
<div class="label-row">
<label for="target-editor-max-milliamps" data-i18n="targets.power_limit">Max current (ABL):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.power_limit.hint">Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.</small>
<div class="label-row">
<input type="number" id="target-editor-max-milliamps" min="0" max="200000" step="100" value="0">
<span data-i18n="targets.power_limit.ma_suffix">mA (0 = unlimited)</span>
</div>
<div class="label-row">
<label for="target-editor-ma-per-led" data-i18n="targets.power_limit.per_led">mA per LED (full white):</label>
<input type="number" id="target-editor-ma-per-led" min="1" max="200" step="1" value="55">
</div>
</div>
</div> </div>
</details> </details>
</div> </div>
</section> </section>
<!-- ── 04 · POWER ──────────────────────────────────── -->
<section class="ds-section" data-ds-key="power" data-ch="coral">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.power">Power</span>
<span class="ds-section-index" aria-hidden="true">04</span>
</div>
<div class="ds-section-body">
<div class="form-group" id="target-editor-power-limit-group">
<div class="label-row">
<label for="target-editor-max-milliamps" data-i18n="targets.power_limit">Max current (ABL):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.power_limit.hint">Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.</small>
<div class="label-row">
<input type="number" id="target-editor-max-milliamps" min="0" max="200000" step="100" value="0">
<span data-i18n="targets.power_limit.ma_suffix">mA (0 = unlimited)</span>
</div>
<div class="label-row">
<label for="target-editor-ma-per-led" data-i18n="targets.power_limit.per_led">mA per LED (full white):</label>
<input type="number" id="target-editor-ma-per-led" min="1" max="200" step="1" value="55">
</div>
</div>
</div>
</section>
<div id="target-editor-error" class="error-message" style="display: none;"></div> <div id="target-editor-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </div>
+39
View File
@@ -0,0 +1,39 @@
"""Spatio-temporal ordered dithering for the final 8-bit quantization.
A smooth gradient quantized to 8 bits per channel bands: adjacent LEDs that
should differ by less than one code round to the same value. Dithering adds a
sub-code threshold that varies per-LED (space) and per-frame (time) before the
floor, so the eye time-averages the strip to a higher effective bit depth
instead of seeing hard steps.
The threshold uses an additive-recurrence (R2 low-discrepancy) sequence: the
per-LED and per-frame phases are irrational multipliers, so the values are
equidistributed in [0, 1) which means ``floor(value + threshold)`` averages
back to ``value`` over time (E[floor(v + U)] = v for the fractional part).
"""
from __future__ import annotations
import numpy as np
# Irrational phase increments (plastic-number / R2 constants) — equidistributed.
_G_LED = 0.7548776662466927
_G_FRAME = 0.5698402909980532
def ordered_dither_quantize(
values: np.ndarray, frame_index: int, led_offset: int = 0
) -> np.ndarray:
"""Quantize float sRGB values in [0, 255] to uint8 with spatio-temporal dither.
``values`` is an ``(N, 3)`` float array. The same per-LED threshold is
applied to all three channels (so hue is preserved); it shifts each frame
via ``frame_index``. ``led_offset`` keeps the spatial phase continuous when
a strip is mapped one edge/segment at a time.
"""
n = values.shape[0]
idx = np.arange(led_offset, led_offset + n, dtype=np.float64)
thr = np.mod(idx * _G_LED + frame_index * _G_FRAME, 1.0).astype(np.float32)[:, None]
out = np.floor(values + thr)
np.clip(out, 0, 255, out=out)
return out.astype(np.uint8)
+48
View File
@@ -0,0 +1,48 @@
"""sRGB ↔ linear-light conversion helpers (LUT-accelerated).
Averaging/blending colours in gamma-encoded sRGB space is perceptually wrong:
the mean of two sRGB values is darker and less saturated than the physically
correct mean of their light intensities. These helpers let the per-LED
reduction blend in linear light and convert back to sRGB for the wire.
Decode is a 256-entry lookup (exact sRGB EOTF); encode is the analytic inverse
applied to the small per-LED result array, so the hot path stays cheap.
"""
from __future__ import annotations
import numpy as np
def _build_srgb_to_linear_lut() -> np.ndarray:
s = np.arange(256, dtype=np.float64) / 255.0
linear = np.where(s <= 0.04045, s / 12.92, ((s + 0.055) / 1.055) ** 2.4)
return linear.astype(np.float32)
# uint8 sRGB index → linear [0, 1] (float32)
SRGB_TO_LINEAR_LUT = _build_srgb_to_linear_lut()
def srgb_to_linear(arr_uint8: np.ndarray) -> np.ndarray:
"""Map a uint8 sRGB array to float32 linear-light in [0, 1] via LUT.
Output shares the input shape; the conversion is per-channel.
"""
return SRGB_TO_LINEAR_LUT[arr_uint8]
def linear_to_srgb_float(linear: np.ndarray) -> np.ndarray:
"""Map float32 linear-light [0, 1] to sRGB float in [0, 255] (no rounding).
Split out from :func:`linear_to_srgb_uint8` so a caller that wants to dither
the final quantization can get the un-rounded sRGB value.
"""
x = np.clip(linear, 0.0, 1.0)
srgb = np.where(x <= 0.0031308, x * 12.92, 1.055 * np.power(x, 1.0 / 2.4) - 0.055)
return (srgb * 255.0).astype(np.float32)
def linear_to_srgb_uint8(linear: np.ndarray) -> np.ndarray:
"""Map float32 linear-light [0, 1] back to uint8 sRGB (inverse EOTF)."""
return np.clip(linear_to_srgb_float(linear) + 0.5, 0, 255).astype(np.uint8)
+95
View File
@@ -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
@@ -0,0 +1,102 @@
"""Tests for the manual-trigger automation route (POST /automations/{id}/trigger).
The AutomationEngine is replaced with a lightweight fake so the route layer is
tested without driving the real evaluation loop or scene application.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from ledgrab.api import dependencies as deps
from ledgrab.api.routes.automations import router
from ledgrab.storage.automation import ManualTriggerRule
from ledgrab.storage.automation_store import AutomationStore
class FakeEngine:
"""Stand-in exposing only what the trigger route calls."""
def __init__(self, result=("triggered", [])):
self.result = result
self.calls = []
async def fire_manual_trigger(self, automation):
self.calls.append(automation.id)
return self.result
@pytest.fixture
def _route_db(tmp_path):
from ledgrab.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def automation_store(_route_db) -> AutomationStore:
store = AutomationStore(_route_db)
store.create_automation(
name="Manual one",
enabled=True,
rule_logic="or",
rules=[ManualTriggerRule()],
scene_preset_id=None,
)
return store
@pytest.fixture
def fake_engine():
return FakeEngine()
@pytest.fixture
def client(automation_store, fake_engine):
app = FastAPI()
app.include_router(router)
from ledgrab.api.auth import verify_api_key
app.dependency_overrides[verify_api_key] = lambda: "test-user"
app.dependency_overrides[deps.get_automation_store] = lambda: automation_store
app.dependency_overrides[deps.get_automation_engine] = lambda: fake_engine
# Routes may fire entity events through the processor manager; give it a stub.
deps._deps["processor_manager"] = None
return TestClient(app, raise_server_exceptions=False)
def _first_id(store: AutomationStore) -> str:
return store.get_all_automations()[0].id
class TestTriggerRoute:
def test_trigger_returns_status(self, client, automation_store, fake_engine):
aid = _first_id(automation_store)
resp = client.post(f"/api/v1/automations/{aid}/trigger")
assert resp.status_code == 200
assert resp.json() == {"status": "triggered", "errors": []}
assert fake_engine.calls == [aid]
def test_trigger_skipped(self, client, automation_store, fake_engine):
fake_engine.result = ("skipped", [])
aid = _first_id(automation_store)
resp = client.post(f"/api/v1/automations/{aid}/trigger")
assert resp.status_code == 200
assert resp.json()["status"] == "skipped"
def test_trigger_partial_errors(self, client, automation_store, fake_engine):
fake_engine.result = ("partial", ["dev1: timeout"])
aid = _first_id(automation_store)
resp = client.post(f"/api/v1/automations/{aid}/trigger")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "partial"
assert body["errors"] == ["dev1: timeout"]
def test_trigger_unknown_id_404(self, client, fake_engine):
resp = client.post("/api/v1/automations/auto_ghost/trigger")
assert resp.status_code == 404
assert fake_engine.calls == []
@@ -161,10 +161,42 @@ class TestCreateIntegration:
description="My game", description="My game",
tags=["fps"], 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["description"] == "My game"
assert data["tags"] == ["fps"] 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): def test_create_duplicate_name(self, client):
_create_integration(client, name="Unique") _create_integration(client, name="Unique")
resp = client.post( resp = client.post(
@@ -132,8 +132,18 @@ def test_prune_by_max_entries(tmp_db):
recorder = _mock_recorder() recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder) engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
for _ in range(10): # Give each entry a distinct, increasing timestamp and capture insertion
repo.record(_make_entry()) # 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 assert repo.count() == 10
@@ -141,6 +151,10 @@ def test_prune_by_max_entries(tmp_db):
engine._prune() engine._prune()
assert repo.count() == 5 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): def test_prune_disabled_is_noop(tmp_db):
@@ -467,13 +467,18 @@ def test_activity_logged_event_payload_shape():
def test_entry_id_format(): 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, persisted, _ = _make_recorder()
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m") recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
entry_id = persisted[0].id entry_id = persisted[0].id
assert entry_id.startswith("al_"), f"id does not start with 'al_': {entry_id!r}" assert entry_id.startswith("al_"), f"id does not start with 'al_': {entry_id!r}"
suffix = entry_id[3:] 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}" assert all(c in "0123456789abcdef" for c in suffix), f"id suffix is not hex: {suffix!r}"
+126 -1
View File
@@ -1,7 +1,7 @@
"""Tests for AutomationEngine — rule evaluation in isolation.""" """Tests for AutomationEngine — rule evaluation in isolation."""
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -10,6 +10,7 @@ from ledgrab.storage.automation import (
ApplicationRule, ApplicationRule,
Automation, Automation,
DisplayStateRule, DisplayStateRule,
ManualTriggerRule,
StartupRule, StartupRule,
SystemIdleRule, SystemIdleRule,
TimeOfDayRule, TimeOfDayRule,
@@ -552,6 +553,130 @@ class TestHTTPValueStreamExtraction:
assert _extract_simple_path(body, "MediaContainer.size", "") == 2 assert _extract_simple_path(body, "MediaContainer.size", "") == 2
assert _extract_simple_path(body, "MediaContainer.Metadata[0].title", "") == "Show" 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): def test_chained_indices(self):
from ledgrab.core.processing.value_stream import _extract_simple_path from ledgrab.core.processing.value_stream import _extract_simple_path
@@ -21,6 +21,7 @@ from ledgrab.storage.automation import (
HTTPPollRule, HTTPPollRule,
MQTTRule, MQTTRule,
Rule, Rule,
SolarRule,
StartupRule, StartupRule,
SystemIdleRule, SystemIdleRule,
TimeOfDayRule, TimeOfDayRule,
@@ -31,6 +32,7 @@ EXPECTED_RULE_TYPES = {
StartupRule, StartupRule,
ApplicationRule, ApplicationRule,
TimeOfDayRule, TimeOfDayRule,
SolarRule,
SystemIdleRule, SystemIdleRule,
DisplayStateRule, DisplayStateRule,
MQTTRule, MQTTRule,
@@ -13,6 +13,7 @@ Coverage targets
from __future__ import annotations from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -233,7 +234,7 @@ class TestAuthInstrumentation:
req = self._make_mock_request() req = self._make_mock_request()
with pytest.raises(Exception): # HTTPException 401 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 # At least one warning record about auth
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING] 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") req = self._make_mock_request(client_ip="127.0.0.1")
with pytest.raises(Exception): # HTTPException 401 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 # At least one warning-level auth record
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING] 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") req = self._make_mock_request(client_ip="192.168.1.100")
with pytest.raises(Exception): # HTTPException 401 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] warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1 assert len(warnings) >= 1
@@ -302,7 +303,7 @@ class TestAuthInstrumentation:
req = self._make_mock_request(client_ip="10.0.0.5") req = self._make_mock_request(client_ip="10.0.0.5")
with pytest.raises(Exception): 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] auth_records = [e for e in persisted if e.category == ActivityCategory.AUTH]
assert len(auth_records) >= 1 assert len(auth_records) >= 1
@@ -7,6 +7,7 @@ shape, and self-referential exclusion.
from __future__ import annotations from __future__ import annotations
import asyncio
import time import time
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -95,7 +96,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
with pytest.raises(Exception): 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" assert len(persisted) >= 1, "Expected at least one auth record"
for entry in persisted: for entry in persisted:
@@ -121,7 +122,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
with pytest.raises(Exception): 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] warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1 assert len(warnings) >= 1
@@ -152,7 +153,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
with pytest.raises(Exception): 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] warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1 assert len(warnings) >= 1
@@ -284,7 +285,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
with pytest.raises(Exception): with pytest.raises(Exception):
verify_api_key(req, creds) asyncio.run(verify_api_key(req, creds))
for entry in persisted: for entry in persisted:
for v in entry.metadata.values(): for v in entry.metadata.values():
@@ -367,7 +368,7 @@ class TestBestEffortResilience:
# Should raise the HTTP 401, not the recorder RuntimeError # Should raise the HTTP 401, not the recorder RuntimeError
with pytest.raises(Exception) as exc_info: 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 # Must be an HTTPException (401), NOT the RuntimeError from the recorder
assert "RuntimeError" not in type(exc_info.value).__name__ assert "RuntimeError" not in type(exc_info.value).__name__
@@ -734,7 +735,7 @@ class TestNoDuplicateRecords:
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
with pytest.raises(Exception): 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"] 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)}" 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 mock_cfg.return_value = cfg
with pytest.raises(Exception): 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"] rejected = [e for e in persisted if e.action == "auth.rejected"]
assert len(rejected) >= 1 assert len(rejected) >= 1
@@ -800,7 +801,7 @@ class TestMetadataShape:
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
with pytest.raises(Exception): 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"] rejected = [e for e in persisted if e.action == "auth.rejected"]
assert rejected[0].metadata["client"] == _EXPECTED_IP assert rejected[0].metadata["client"] == _EXPECTED_IP
@@ -1041,7 +1042,7 @@ class TestCategorySeverityContract:
cfg.auth.api_keys = {"dev": "good"} cfg.auth.api_keys = {"dev": "good"}
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
with pytest.raises(Exception): with pytest.raises(Exception):
verify_api_key(req, creds) asyncio.run(verify_api_key(req, creds))
self._check_all_entries(persisted) self._check_all_entries(persisted)
auth_records = [e for e in persisted if e.category == "auth"] auth_records = [e for e in persisted if e.category == "auth"]
@@ -1384,7 +1385,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "correct-key"} cfg.auth.api_keys = {"dev": "correct-key"}
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
with pytest.raises(Exception): with pytest.raises(Exception):
verify_api_key(req, creds) asyncio.run(verify_api_key(req, creds))
return persisted_ref return persisted_ref
@@ -1414,7 +1415,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "real-key"} cfg.auth.api_keys = {"dev": "real-key"}
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
try: try:
verify_api_key(req, creds) asyncio.run(verify_api_key(req, creds))
except Exception: except Exception:
exceptions_raised += 1 exceptions_raised += 1
@@ -1444,7 +1445,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "right"} cfg.auth.api_keys = {"dev": "right"}
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
with pytest.raises(Exception): 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"] rejected = [e for e in all_persisted if e.action == "auth.rejected"]
assert len(rejected) == len( assert len(rejected) == len(
@@ -1477,7 +1478,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "correct"} cfg.auth.api_keys = {"dev": "correct"}
mock_cfg.return_value = cfg mock_cfg.return_value = cfg
with pytest.raises(Exception): 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"] rejected = [e for e in all_persisted if e.action == "auth.rejected"]
assert ( assert (

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