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.
This commit is contained in:
2026-06-22 23:21:24 +03:00
parent 126d8f2449
commit 6745e25b20
91 changed files with 4390 additions and 540 deletions
+52
View File
@@ -354,6 +354,58 @@ jobs:
docker push "$REGISTRY:latest"
fi
# Best-effort arm64 (Raspberry Pi / arm64 HAOS hosts). Runs AFTER the
# amd64 push so the amd64 image always ships even if this fails.
# Deliberately avoids `docker buildx` (its docker-container driver needs
# nested networking the TrueNAS runners lack — see contexts/ci-cd.md):
# instead it cross-builds a single arm64 image via QEMU binfmt and folds
# amd64 + arm64 into multi-arch manifest lists under the existing tags.
# `continue-on-error` keeps a runner that can't emulate arm64 from
# failing the release; the plain amd64 tags pushed above remain valid.
- name: Build + publish arm64 (multi-arch manifest, best-effort)
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
continue-on-error: true
run: |
set -e
export DOCKER_CLI_EXPERIMENTAL=enabled
TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}"
VERSION="${{ steps.meta.outputs.version }}"
# Register arm64 emulation. If the runner forbids privileged
# containers this fails and the whole step is skipped.
docker run --privileged --rm tonistiigi/binfmt --install arm64
# Cross-build the arm64 image (QEMU-emulated — slow but uses arm64
# manylinux wheels, so no source compilation). Stays in the local
# daemon alongside the amd64 image from the previous build step.
DOCKER_BUILDKIT=1 docker build \
--platform linux/arm64 \
--build-arg APP_VERSION="$VERSION" \
--label "org.opencontainers.image.version=$VERSION" \
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
-t "$REGISTRY:$VERSION-arm64" \
./server
# Fold amd64 + arm64 into a multi-arch manifest list under each
# user-facing tag. The arch-suffixed tags remain pullable directly.
publish_manifest() {
local t="$1"
docker tag "$REGISTRY:$t" "$REGISTRY:$t-amd64"
docker push "$REGISTRY:$t-amd64"
docker tag "$REGISTRY:$VERSION-arm64" "$REGISTRY:$t-arm64"
docker push "$REGISTRY:$t-arm64"
docker manifest create --amend "$REGISTRY:$t" \
"$REGISTRY:$t-amd64" "$REGISTRY:$t-arm64"
docker manifest push "$REGISTRY:$t"
}
publish_manifest "$TAG"
publish_manifest "$VERSION"
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
publish_manifest "latest"
fi
# ── Publish the release (flip draft=false) ─────────────────
# Runs only after every build job succeeded so users never see a
# release that's missing artifacts or sha256 sidecars (the in-app
+33 -2
View File
@@ -2,9 +2,40 @@
## Code Search
**If `ast-index` is available, use it as the PRIMARY code search tool.** It is significantly faster than grep and returns structured, accurate results. Fall back to grep/Glob only when ast-index is not installed, returns empty results, or when searching regex patterns/string literals/comments.
**Priority order: `vex` (PRIMARY) → `ast-index` (fallback) → Grep/Glob (last resort).** This repo has a fully-featured `.vex.toml` index. Use vex first for any symbol/definition/usage/call-graph lookup. Fall back to ast-index only when vex legitimately can't help, and to Grep/Glob only for regex patterns, string literals, comments, config files, or unparsed languages.
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt.
**IMPORTANT — use ALL vex indexing features.** The index is built with every capability enabled, and queries must take advantage of them. Keep them ON and exploit them:
| Capability | Status | Powers |
| ---------- | ------ | ------ |
| Semantic embeddings (`jina-code`, 768-dim) | ON | `vex search` (semantic channel), `similar`, `find_similar`, `duplicates` |
| Call graph | ON | `vex callers`, `callees`, `paths`, `reachable`, `bundle --mode pr-impact` |
| BM25 | ON | hybrid RRF text channel in `vex search` |
| Pattern index | ON | `vex pattern` AST-shape matching |
| C++ includes | ON | include-graph resolution |
| Body tokens (incremental HNSW) | ON | fast incremental reindex |
| History | ON | `vex history`, `vex diff <rev>` blame/evolution queries |
**In-session, use the `mcp__vex__*` MCP tools** (`search`, `show`, `usages`, `callers`, `callees`, `bundle`, `outline`, `implementations`, `similar`, `grep`, `status`, etc.) — MCP output is far cheaper in tokens than `Bash("vex …")`. Drop to Bash `vex` only for CLI-only features (`pattern`, `diff`, `paths`, `reachable`, `bundle`, `history`, `--strict`/`--why` flags), for subagent prompts, or for shell composition.
```bash
vex search "query" --semantic # Hybrid semantic + BM25 search
vex show <Symbol> # Definition body (prefer over Read)
vex usages <Symbol> --strict # Reference sites (AST-precise on T1 langs)
vex callers <Function> # Call sites (function-scoped)
vex callees <Function> # Outgoing calls
vex paths --from <A> --to <B> # Multi-hop call-graph path
vex bundle --mode pr-impact --base master # Changed symbols + callers + reachable tests
vex pattern '$X async fn returning Response' # AST-shape (metavariables)
vex diff master # Symbol-level branch diff
vex history <Symbol> # Commit evolution of a symbol
```
**Maintenance:** the index has `auto_update = true`, so it refreshes on stale queries. After a `vex self-update`, rerun `vex index --history --semantic --embedder jina-code --device cuda` so newly-added extractors populate and all features stay enabled. Verify with `vex status` — every capability line should read `yes`.
**IMPORTANT for subagents:** Subagents don't inherit MCP. When spawning Agent subagents (Plan, Explore, general-purpose, etc.), instruct them to use `vex` via Bash for code search (e.g. include "Use `vex search`, `vex show`, `vex usages`, `vex callers` via Bash for code search; ast-index is the fallback"). Don't tell them to default to grep/Glob.
**Fallback — `ast-index`** (use only when vex is unavailable):
```bash
ast-index search "Query" # Universal search
@@ -32,15 +32,15 @@ class ApiKeyManager(context: Context) {
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
// or absent keystore, or a key got corrupted), creation throws — fall back
// to plain SharedPreferences so a keystore failure NEVER bricks the local
// API key (which would 401 every LAN client). [encrypted] records which
// path we took so we don't repeatedly attempt migration.
private val encrypted: Boolean
// API key (which would 401 every LAN client).
private val prefs: SharedPreferences
init {
val (store, isEncrypted) = buildPrefs(appContext)
prefs = store
encrypted = isEncrypted
// Only run the plain→encrypted migration when the encrypted store is
// actually available; on the degraded plain path there is nothing to
// migrate INTO (and recoverLegacyKey reads the backup directly).
if (isEncrypted) migrateLegacyKeyIfPresent()
}
@@ -113,26 +113,48 @@ class ApiKeyManager(context: Context) {
*/
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
return try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val store = EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
store to true
createEncrypted(context) to true
} catch (e: Exception) {
// Keystore unavailable/corrupt — degrade to plain prefs rather
// than crashing. Worst case the key is stored unencrypted on a
// single-user TV box, which is the pre-existing behaviour.
Log.w(TAG, "EncryptedSharedPreferences unavailable, using plain prefs: ${e.message}")
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
// The keystore can become invalidated (OS upgrade, device restore,
// OEM keystore bug), after which create() throws on EVERY launch and
// the corrupt encrypted file is never cleaned up — degrading to plain
// prefs forever and (because the live key was only in the encrypted
// store) rotating the per-install key on the next mint, 401-ing every
// paired client. Self-heal once: delete the corrupt store + master key
// alias and retry create() before degrading.
Log.w(TAG, "EncryptedSharedPreferences unavailable, attempting one-time reset: ${e.message}")
runCatching {
context.deleteSharedPreferences(ENCRYPTED_PREFS_NAME)
runCatching {
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (ks.containsAlias(MasterKey.DEFAULT_MASTER_KEY_ALIAS)) {
ks.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
}
}.onFailure { Log.w(TAG, "Master-key alias cleanup failed: ${it.message}") }
createEncrypted(context) to true
}.getOrElse {
// Still failing after reset — degrade to plain prefs rather than
// crashing. Worst case the key is stored unencrypted on a
// single-user TV box, which is the pre-existing behaviour.
Log.w(TAG, "EncryptedSharedPreferences still unavailable after reset, using plain prefs: ${it.message}")
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
}
}
}
private fun createEncrypted(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
/**
* One-time migration: if a key exists in the legacy plain-text prefs file
* (from before encrypted storage), copy it into the encrypted store and
@@ -176,12 +198,17 @@ class ApiKeyManager(context: Context) {
/**
* Recover a still-present key from the legacy plain store — either the live
* key (failed/never-run migration) or the `.migrated` backup. Returns null
* when on the plain-prefs path (no legacy/encrypted split) or no valid key
* survives. Guarantees [getOrCreateKey] never rotates an existing key as long
* as the legacy file survives.
* only when no valid key survives.
*
* This MUST run on the degraded plain-prefs path too (not just the encrypted
* path): after a successful migration the live key is moved to the
* `.migrated` backup in this same plain file, so when the keystore later
* fails and we degrade to plain prefs, the backup is the only surviving
* copy. Returning null here (the previous `if (!encrypted) return null`
* guard) would mint a fresh key and rotate the per-install key, 401-ing every
* paired client.
*/
private fun recoverLegacyKey(): String? {
if (!encrypted) return null
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val candidate = legacy.getString(KEY_API_KEY, null)
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
@@ -42,6 +42,12 @@ class LedGrabNotificationListener : NotificationListenerService() {
// onListenerConnected can't race two executors into existence.
private val executorLock = Any()
// Tracks whether the listener is currently connected. ensureExecutor() only
// CREATES a new executor while connected — otherwise a notification racing
// onListenerDisconnected (which nulls pushExecutor) would spin up a fresh
// executor that nothing reaps until the next disconnect cycle (a thread leak).
@Volatile private var connected: Boolean = false
// packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working.
// Naturally bounded by the number of notification-posting apps (tens) and
@@ -74,7 +80,10 @@ class LedGrabNotificationListener : NotificationListenerService() {
// executor that onListenerDisconnected is shutting down throws
// RejectedExecutionException — guard with runCatching so a notification
// racing teardown can never crash this system-bound service.
val executor = ensureExecutor()
val executor = ensureExecutor() ?: run {
Log.d(TAG, "no executor (listener disconnected) — skipping push")
return
}
runCatching {
executor.execute {
try {
@@ -116,13 +125,16 @@ class LedGrabNotificationListener : NotificationListenerService() {
}
/**
* Return the push executor, creating it under [executorLock] if absent.
* Safe against a concurrent onListenerConnected/onNotificationPosted race
* (single executor) and against a missing onListenerConnected callback.
* Return the push executor, creating it under [executorLock] if absent AND
* the listener is connected. Returns null when disconnected so a notification
* racing teardown neither submits onto a shutting-down executor nor spins up
* a stray one. Safe against a concurrent onListenerConnected/onNotificationPosted
* race (single executor) and against a missing onListenerConnected callback.
*/
private fun ensureExecutor(): ExecutorService {
private fun ensureExecutor(): ExecutorService? {
pushExecutor?.let { return it }
synchronized(executorLock) {
if (!connected) return null
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
}
}
@@ -134,15 +146,16 @@ class LedGrabNotificationListener : NotificationListenerService() {
// executor here rather than in onCreate/onDestroy. onNotificationPosted
// also lazily creates it (via ensureExecutor) in case this callback is
// late or skipped on some ROMs.
connected = true
ensureExecutor()
}
override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected")
// Tear the executor down on disconnect; a fresh one is created on the
// next onListenerConnected. Null out first so any in-flight
// onNotificationPosted snapshots see null (skips submit) rather than
// racing a shutdown executor.
// Mark disconnected BEFORE nulling the executor so a racing ensureExecutor
// sees !connected and skips creating a replacement. Tear the executor
// down; a fresh one is created on the next onListenerConnected.
connected = false
pushExecutor?.let { exec ->
pushExecutor = null
exec.shutdown()
@@ -152,6 +165,7 @@ class LedGrabNotificationListener : NotificationListenerService() {
override fun onDestroy() {
// Defensive: onListenerDisconnected normally clears this first, but
// shut down here too in case onDestroy fires without a prior disconnect.
connected = false
pushExecutor?.shutdown()
pushExecutor = null
super.onDestroy()
+7 -1
View File
@@ -31,9 +31,15 @@ Creates the Gitea release with a description table listing all artifacts. **The
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
### 4. `build-docker`
- Plain `docker build` + `docker push` (no Buildx — TrueNAS runners lack nested networking)
- Plain `docker build` + `docker push` for **amd64** (no Buildx — TrueNAS runners lack nested networking)
- Registry: `{gitea_host}/{repo}:{tag}`
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
- **arm64 is best-effort** (Raspberry Pi / arm64 HAOS): a `continue-on-error` step
cross-builds arm64 via **QEMU binfmt** (`tonistiigi/binfmt`) + `docker manifest`
(NOT buildx — sidesteps the docker-container-driver networking limit) and folds
amd64 + arm64 into multi-arch manifest lists under the same tags, plus
`:{tag}-amd64` / `:{tag}-arm64` arch-suffixed tags. If the runner can't run
privileged binfmt the step is skipped and the amd64 tags above remain valid.
## Build Scripts
+81 -35
View File
@@ -3,7 +3,9 @@
import asyncio
import json
import secrets
import threading
import time
from collections import OrderedDict
from typing import Annotated
from urllib.parse import urlparse
@@ -31,15 +33,26 @@ logger = get_logger(__name__)
# suppressed — only the *audit recording* is de-duplicated.
#
# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP
# entries. When the cap is exceeded the oldest-seen IP (lowest timestamp) is
# evicted so the dict stays bounded regardless of the number of distinct source
# IPs an attacker can forge.
# entries. When the cap is exceeded the oldest-inserted IP is evicted in O(1)
# so the dict stays bounded regardless of the number of distinct source IPs an
# attacker can forge.
#
# Thread safety: the throttle dict is guarded by ``_auth_record_lock`` (mirrors
# ``_auth_fail_lock`` in routes/game_integration) so the compound
# read/evict/insert is atomic. The HTTP auth dependency runs on the event loop
# (``verify_api_key`` is async), but ``_record_auth_failure`` is reached from
# both the HTTP and WebSocket auth paths and must remain safe if ever called
# from a background thread — the lock is uncontended on the loop, so it costs
# nothing while preventing a KeyError / "dict changed size" from ever turning
# an intended 401 into a 500.
_AUTH_RECORD_WINDOW: float = 10.0 # seconds — one record per IP per window
_AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously
# ip -> monotonic timestamp of last *recorded* auth.rejected entry
_auth_record_last: dict[str, float] = {}
# ip -> monotonic timestamp of last *recorded* auth.rejected entry.
# OrderedDict so the oldest insertion can be evicted in O(1) via popitem.
_auth_record_last: "OrderedDict[str, float]" = OrderedDict()
_auth_record_lock = threading.Lock()
def _should_record_auth_failure(client_ip: str) -> bool:
@@ -48,19 +61,26 @@ def _should_record_auth_failure(client_ip: str) -> bool:
Suppresses duplicates within _AUTH_RECORD_WINDOW seconds. Evicts the
oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent
unbounded memory growth under IP-spray attacks.
Thread-safe: the entire read/evict/insert is performed under
``_auth_record_lock`` so concurrent threadpool workers cannot corrupt the
dict or raise mid-mutation.
"""
now = time.monotonic()
last = _auth_record_last.get(client_ip)
if last is not None and (now - last) < _AUTH_RECORD_WINDOW:
return False # suppress: within the de-dup window
with _auth_record_lock:
last = _auth_record_last.get(client_ip)
if last is not None and (now - last) < _AUTH_RECORD_WINDOW:
return False # suppress: within the de-dup window
# Enforce hard cap before inserting: evict the single oldest entry.
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
oldest_ip = min(_auth_record_last, key=lambda ip: _auth_record_last[ip])
del _auth_record_last[oldest_ip]
# Enforce hard cap before inserting: evict the oldest entry in O(1).
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
_auth_record_last.popitem(last=False)
_auth_record_last[client_ip] = now
return True
# Refresh recency: move/insert this IP to the most-recent end so the
# popitem(last=False) above always drops a genuinely old entry.
_auth_record_last[client_ip] = now
_auth_record_last.move_to_end(client_ip)
return True
def _record_auth_failure(reason: str, client_host: str | None) -> None:
@@ -72,23 +92,29 @@ def _record_auth_failure(reason: str, client_host: str | None) -> None:
THROTTLE: at most one ``auth.rejected`` record is written per client IP
per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification
DoS. The 401 response is always returned regardless.
The whole body is wrapped so an audit-path failure can never convert an
intended 401 into a 500 (honors the "never raises" contract).
"""
if not _should_record_auth_failure(client_host or "unknown"):
return # throttled — drop duplicate recording for this IP/window
try:
if not _should_record_auth_failure(client_host or "unknown"):
return # throttled — drop duplicate recording for this IP/window
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.rejected",
severity=ActivitySeverity.WARNING,
actor="anonymous",
message=f"Authentication failed: {reason}",
metadata={"reason": reason, "client": client_host or "unknown"},
)
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.rejected",
severity=ActivitySeverity.WARNING,
actor="anonymous",
message=f"Authentication failed: {reason}",
metadata={"reason": reason, "client": client_host or "unknown"},
)
except Exception as exc: # never raise into the auth path
logger.warning("auth-failure audit recording failed: %s", exc)
def _record_ws_auth_success(label: str, client_host: str | None) -> None:
@@ -142,7 +168,7 @@ def _is_loopback(host: str | None) -> bool:
return _classify_is_loopback(host)
def verify_api_key(
async def verify_api_key(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str:
@@ -156,6 +182,13 @@ def verify_api_key(
LAN access requires an API key).
- When API keys ARE configured, valid Bearer credentials are required.
This is an ``async`` dependency on purpose: token comparison is CPU-trivial,
and an async dependency runs in the SAME task/context as the route handler,
so ``current_actor.set(...)`` below is visible to ``ActivityRecorder`` when
the handler later records an entity event. A sync dependency would run in a
throwaway threadpool context and the actor mutation would be discarded,
attributing every audited action to "system".
Args:
request: incoming request (used to read client host)
credentials: HTTP authorization credentials
@@ -202,10 +235,16 @@ def verify_api_key(
# Extract token — NEVER log or record the token value itself.
token = credentials.credentials
# Find matching key and return its label using constant-time comparison
# Find matching key and return its label using constant-time comparison.
# Compare UTF-8 byte encodings: secrets.compare_digest raises TypeError on
# non-ASCII str (an attacker can put 0x80-0xFF in the Authorization header,
# which Starlette latin-1-decodes to a non-ASCII str). Byte comparison is
# well-defined for any input and preserves constant-time behavior, so a
# bad/non-ASCII token cleanly falls through to the 401 below instead of 500.
token_b = (token or "").encode("utf-8")
authenticated_as = None
for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
authenticated_as = label
break
@@ -235,7 +274,7 @@ def verify_api_key(
AuthRequired = Annotated[str, Depends(verify_api_key)]
def verify_docs_access(
async def verify_docs_access(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str:
@@ -253,7 +292,7 @@ def verify_docs_access(
if get_config().auth.expose_docs:
request.state.auth_label = "anonymous-docs"
return "anonymous-docs"
return verify_api_key(request, credentials)
return await verify_api_key(request, credentials)
# Dependency for the OpenAPI docs routes — relaxed when auth.expose_docs is set
@@ -361,12 +400,19 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
def _match_api_key(token: str) -> str | None:
"""Return the label matching *token* using constant-time comparison, or None."""
"""Return the label matching *token* using constant-time comparison, or None.
Compares UTF-8 byte encodings so a non-ASCII token (a JSON string in the WS
auth message trivially carries non-ASCII) cannot raise TypeError out of
``secrets.compare_digest`` — it simply fails to match and yields a clean
``auth_error`` instead of crashing the handler.
"""
config = get_config()
if not token:
return None
token_b = token.encode("utf-8")
for label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
return label
return None
+40 -24
View File
@@ -37,6 +37,7 @@ from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
@@ -173,6 +174,15 @@ def get_game_event_bus() -> GameEventBus:
return _get("game_event_bus", "Game event bus")
def get_lol_poll_manager() -> LoLPollManager | None:
"""LoL poll manager, or None if not wired (e.g. minimal test harnesses).
Polling is a best-effort background feature, so callers guard on None
rather than 500-ing a CRUD request when the manager is absent.
"""
return _deps.get("lol_poll_manager")
def get_mqtt_store() -> MQTTSourceStore:
return _get("mqtt_store", "MQTT source store")
@@ -216,36 +226,40 @@ def get_activity_log_retention_engine() -> ActivityLogRetentionEngine:
# ── Event helper ────────────────────────────────────────────────────────
# entity_type → (_deps key, store method name) for human-name resolution.
# Module-level constant: built once at import rather than per audited mutation
# (``_resolve_entity_name`` is the create/update audit choke point).
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
"output_target": ("output_target_store", "get_target"),
"device": ("device_store", "get_device"),
"picture_source": ("picture_source_store", "get_source"),
"audio_source": ("audio_source_store", "get_source"),
"color_strip_source": ("color_strip_store", "get_source"),
"template": ("template_store", "get_template"),
"capture_template": ("template_store", "get_template"),
"pp_template": ("pp_template_store", "get_template"),
"automation": ("automation_store", "get_automation"),
"scene_preset": ("scene_preset_store", "get_preset"),
"scene_playlist": ("scene_playlist_store", "get_playlist"),
"sync_clock": ("sync_clock_store", "get_clock"),
"gradient": ("gradient_store", "get_gradient"),
"audio_template": ("audio_template_store", "get_template"),
"value_source": ("value_source_store", "get_source"),
"cspt": ("cspt_store", "get_template"),
"audio_processing_template": ("audio_processing_template_store", "get_template"),
"pattern_template": ("pattern_template_store", "get_template"),
"home_assistant_source": ("ha_store", "get_source"),
"mqtt_source": ("mqtt_store", "get_source"),
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
}
def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None:
"""Best-effort: look up a human name for *entity_id* from the matching store.
Returns ``None`` when the store is missing, the entity is gone, or any
exception occurs (e.g. during delete the entity may have just been removed).
"""
# Map entity_type → (_deps key, method name on the store)
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
"output_target": ("output_target_store", "get_target"),
"device": ("device_store", "get_device"),
"picture_source": ("picture_source_store", "get_source"),
"audio_source": ("audio_source_store", "get_source"),
"color_strip_source": ("color_strip_store", "get_source"),
"template": ("template_store", "get_template"),
"capture_template": ("template_store", "get_template"),
"pp_template": ("pp_template_store", "get_template"),
"automation": ("automation_store", "get_automation"),
"scene_preset": ("scene_preset_store", "get_preset"),
"scene_playlist": ("scene_playlist_store", "get_playlist"),
"sync_clock": ("sync_clock_store", "get_clock"),
"gradient": ("gradient_store", "get_gradient"),
"audio_template": ("audio_template_store", "get_template"),
"value_source": ("value_source_store", "get_source"),
"cspt": ("cspt_store", "get_template"),
"audio_processing_template": ("audio_processing_template_store", "get_template"),
"pattern_template": ("pattern_template_store", "get_template"),
"home_assistant_source": ("ha_store", "get_source"),
"mqtt_source": ("mqtt_store", "get_source"),
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
}
entry = _STORE_LOOKUP.get(entity_type)
if entry is None:
return None
@@ -356,6 +370,7 @@ def init_dependencies(
ha_manager: HomeAssistantManager | None = None,
game_integration_store: GameIntegrationStore | None = None,
game_event_bus: GameEventBus | None = None,
lol_poll_manager: LoLPollManager | None = None,
mqtt_store: MQTTSourceStore | None = None,
mqtt_manager: MQTTManager | None = None,
http_endpoint_store: HTTPEndpointStore | None = None,
@@ -397,6 +412,7 @@ def init_dependencies(
"ha_manager": ha_manager,
"game_integration_store": game_integration_store,
"game_event_bus": game_event_bus,
"lol_poll_manager": lol_poll_manager,
"mqtt_store": mqtt_store,
"mqtt_manager": mqtt_manager,
"http_endpoint_store": http_endpoint_store,
+58 -26
View File
@@ -57,6 +57,7 @@ from ledgrab.api.schemas.activity_log import (
)
from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogFilters, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
@@ -66,6 +67,12 @@ router = APIRouter(prefix="/api/v1/activity-log", tags=["Activity Log"])
_MAX_LIMIT = 200
_DEFAULT_LIMIT = 50
# Bounds on the text filter params so a multi-KB ``q`` / actor / entity filter
# can't enlarge the LIKE pattern and bound params per page (FastAPI returns 422
# on overflow). The free-text ``q`` gets a larger budget than the id filters.
_MAX_TEXT_FILTER = 256
_MAX_ID_FILTER = 128
# CSV export columns (matches entry_to_dict key order)
_CSV_COLUMNS = [
"id",
@@ -85,6 +92,11 @@ _CSV_COLUMNS = [
# Leading TAB and CR are also recognised triggers by Excel / Google Sheets.
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
# Cap for export-cell sanitization. Effectively no truncation (a single audit
# field never approaches this) — we reuse sanitize_display only to strip
# NUL/control/ANSI from CSV cells, not to shorten them.
_EXPORT_CELL_MAXLEN = 1_000_000
def _csv_safe(value: str) -> str:
"""Prefix formula-injection triggers with a literal single-quote.
@@ -97,6 +109,23 @@ def _csv_safe(value: str) -> str:
return value
def _redact_for_anon(entry_dict: dict, auth_label: str) -> dict:
"""Redact the source-IP metadata for anonymous (loopback) callers.
The streaming export is gated by ``require_authenticated`` precisely because
the log can contain client IPs (e.g. ``auth.rejected`` / ``auth.ws_connected``
store ``metadata.client``). The list endpoint allows loopback-anonymous
callers, so to keep the posture consistent we mask that one field for the
``"anonymous"`` label rather than handing it back what export withholds.
"""
if auth_label != "anonymous":
return entry_dict
meta = entry_dict.get("metadata")
if isinstance(meta, dict) and "client" in meta:
return {**entry_dict, "metadata": {**meta, "client": "[redacted]"}}
return entry_dict
def _build_filters(
categories: list[str] | None,
severities: list[str] | None,
@@ -127,7 +156,7 @@ def _build_filters(
@router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries")
def list_activity_log(
auth: AuthRequired, # noqa: ARG001
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Filters ────────────────────────────────────────────────────────────
categories: Annotated[
@@ -145,15 +174,15 @@ def list_activity_log(
] = None,
actor: Annotated[
str | None,
Query(description="Filter by actor label (exact match)"),
Query(max_length=_MAX_ID_FILTER, description="Filter by actor label (exact match)"),
] = None,
entity_type: Annotated[
str | None,
Query(description="Filter by entity type (exact match)"),
Query(max_length=_MAX_ID_FILTER, description="Filter by entity type (exact match)"),
] = None,
entity_id: Annotated[
str | None,
Query(description="Filter by entity id (exact match)"),
Query(max_length=_MAX_ID_FILTER, description="Filter by entity id (exact match)"),
] = None,
since: Annotated[
datetime | None,
@@ -165,7 +194,10 @@ def list_activity_log(
] = None,
q: Annotated[
str | None,
Query(description="Free-text search in the message field (substring)"),
Query(
max_length=_MAX_TEXT_FILTER,
description="Free-text search in the message field (substring)",
),
] = None,
# ── Pagination ─────────────────────────────────────────────────────────
before_seq: Annotated[
@@ -204,27 +236,21 @@ def list_activity_log(
# by slicing [1:], which is the actual page content for the client.
# When we got <= limit rows, this is the last page and all rows are included.
effective_limit = min(limit, _MAX_LIMIT)
entries_plus = repo.query(filters, before_seq=before_seq, limit=effective_limit + 1)
has_more = len(entries_plus) > effective_limit
if has_more:
# Drop the oldest probe row; keep the newest `limit` entries.
entries = entries_plus[1:]
else:
entries = entries_plus
# query_with_seq returns (seq, entry) ascending (oldest-first within page),
# so the seq is already in hand — no extra get_seq_for_id round-trip.
rows_plus = repo.query_with_seq(filters, before_seq=before_seq, limit=effective_limit + 1)
has_more = len(rows_plus) > effective_limit
# When over-fetched, drop the oldest probe row (index 0) and keep the newest.
rows = rows_plus[1:] if has_more else rows_plus
total = repo.count(filters)
# Compute next_before_seq: the seq of the oldest entry on this page.
# query() returns entries ascending (entries[0] is oldest); its seq is the
# cursor for the next page. The next request passes before_seq=X to get
# entries with seq < X, i.e. entries older than the oldest entry on this page.
# get_seq_for_id() does a cheap indexed point-lookup.
next_before_seq: int | None = None
if has_more and entries:
next_before_seq = repo.get_seq_for_id(entries[0].id)
# next_before_seq: the seq of the oldest entry on this page (rows[0]).
# The next request passes before_seq=X to get entries with seq < X.
next_before_seq: int | None = rows[0][0] if (has_more and rows) else None
return ActivityLogPageResponse(
entries=[entry_to_dict(e) for e in entries], # type: ignore[arg-type]
entries=[_redact_for_anon(entry_to_dict(e), auth) for _seq, e in rows], # type: ignore[arg-type]
next_before_seq=next_before_seq,
has_more=has_more,
total=total,
@@ -259,9 +285,15 @@ def _export_csv_generator(
row = []
for col in _CSV_COLUMNS:
if col == "metadata":
# json.dumps escapes control chars (<0x20) as \uXXXX, so the
# metadata cell can't carry raw NUL/CR/ANSI into the file.
cell = json.dumps(d.get(col) or {})
else:
cell = str(d.get(col, "") or "")
# Defense-in-depth: strip NUL/control/ANSI from string cells
# at the export boundary so a (current or future) un-sanitized
# call site can't leak control chars into the CSV. csv.writer
# quotes embedded newlines but does not strip control chars.
cell = sanitize_display(str(d.get(col, "") or ""), maxlen=_EXPORT_CELL_MAXLEN)
row.append(_csv_safe(cell))
buf = io.StringIO()
writer = csv.writer(buf)
@@ -310,12 +342,12 @@ def export_activity_log(
# ── Same filters as list ───────────────────────────────────────────────
categories: Annotated[list[str] | None, Query()] = None,
severities: Annotated[list[str] | None, Query()] = None,
actor: Annotated[str | None, Query()] = None,
entity_type: Annotated[str | None, Query()] = None,
entity_id: Annotated[str | None, Query()] = None,
actor: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
entity_type: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
entity_id: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
since: Annotated[datetime | None, Query()] = None,
until: Annotated[datetime | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
q: Annotated[str | None, Query(max_length=_MAX_TEXT_FILTER)] = None,
) -> StreamingResponse:
"""Stream all matching entries as CSV or JSON.
@@ -15,6 +15,7 @@ from ledgrab.api.schemas.automations import (
AutomationCreate,
AutomationListResponse,
AutomationResponse,
AutomationTriggerResponse,
AutomationUpdate,
RuleSchema,
)
@@ -24,8 +25,10 @@ from ledgrab.storage.automation import (
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
ManualTriggerRule,
MQTTRule,
Rule,
SolarRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
@@ -55,6 +58,20 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
days_of_week=s.days_of_week or [],
timezone=s.timezone or "",
),
# SolarRule.from_dict validates events, clamps offsets/coords, and
# filters weekdays — route the raw schema values through it.
"solar": lambda: SolarRule.from_dict(
{
"start_event": s.start_event,
"start_offset_minutes": s.start_offset_minutes,
"end_event": s.end_event,
"end_offset_minutes": s.end_offset_minutes,
"latitude": s.latitude,
"longitude": s.longitude,
"days_of_week": s.days_of_week or [],
"timezone": s.timezone or "",
}
),
"system_idle": lambda: SystemIdleRule(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
when_idle=s.when_idle if s.when_idle is not None else True,
@@ -72,6 +89,7 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
token=s.token or secrets.token_hex(16),
),
"startup": lambda: StartupRule(),
"manual_trigger": lambda: ManualTriggerRule(),
"home_assistant": lambda: HomeAssistantRule(
ha_source_id=s.ha_source_id or "",
entity_id=s.entity_id or "",
@@ -394,3 +412,33 @@ async def disable_automation(
raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine, request)
# ===== Manual trigger =====
@router.post(
"/api/v1/automations/{automation_id}/trigger",
response_model=AutomationTriggerResponse,
tags=["Automations"],
)
async def trigger_automation(
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Manually fire an automation.
Evaluates the automation's rules with its manual trigger satisfied — so it
"still checks all of the rules" under the automation's ``rule_logic`` — and,
if it should activate, applies its scene once. Independent of the ``enabled``
flag (that gates only the background evaluation loop).
"""
try:
automation = store.get_automation(automation_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
status, errors = await engine.fire_manual_trigger(automation)
return AutomationTriggerResponse(status=status, errors=errors)
+3
View File
@@ -103,6 +103,7 @@ def _device_to_response(device) -> DeviceResponse:
opc_channel=device.opc_channel,
nanoleaf_paired=bool(device.nanoleaf_token),
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
nanoleaf_per_panel=device.nanoleaf_per_panel,
spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type,
@@ -288,6 +289,7 @@ async def create_device(
if device_data.nanoleaf_min_interval_ms is not None
else 100
),
nanoleaf_per_panel=bool(device_data.nanoleaf_per_panel),
spi_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink",
@@ -638,6 +640,7 @@ async def update_device(
opc_channel=update_data.opc_channel,
nanoleaf_token=update_data.nanoleaf_token,
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
nanoleaf_per_panel=update_data.nanoleaf_per_panel,
spi_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type,
@@ -17,6 +17,7 @@ from ledgrab.api.dependencies import (
get_database,
get_game_integration_store,
get_game_event_bus,
get_lol_poll_manager,
)
from ledgrab.api.schemas.game_integration import (
AdapterInfoResponse,
@@ -37,9 +38,16 @@ from ledgrab.api.schemas.game_integration import (
)
from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.events import GameEvent
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
from ledgrab.core.game_integration.runtime_state import (
cleanup_state as _cleanup_state,
get_prev_state as _get_prev_state,
get_stats as _get_stats,
record_events as _record_events,
set_prev_state as _set_prev_state,
)
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.game_integration import EventMapping
from ledgrab.storage.game_integration import _SECRET_CONFIG_KEYS, EventMapping
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.utils import get_logger
@@ -47,15 +55,10 @@ logger = get_logger(__name__)
router = APIRouter()
# ── Per-integration runtime state (in-memory, not persisted) ──────────────
_integration_state_lock = threading.Lock()
# integration_id -> prev_state dict for diff-based trigger detection
_prev_states: dict[str, dict[str, Any]] = {}
# integration_id -> runtime stats
_integration_stats: dict[str, dict[str, Any]] = {}
# Per-integration runtime state (prev-state + stats + payload processing) lives
# in ``core/game_integration/runtime_state.py`` and is imported above under the
# legacy ``_get_prev_state`` / ``_record_events`` / … names so both this route
# and the LoL poll manager share one set of counters.
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
@@ -150,59 +153,47 @@ def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
return fields
def _get_prev_state(integration_id: str) -> dict[str, Any]:
"""Get or create the prev_state dict for an integration."""
with _integration_state_lock:
if integration_id not in _prev_states:
_prev_states[integration_id] = {}
return _prev_states[integration_id]
def _set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
"""Update the prev_state dict for an integration."""
with _integration_state_lock:
_prev_states[integration_id] = state
def _record_events(integration_id: str, events: list[GameEvent]) -> None:
"""Record event stats for an integration."""
with _integration_state_lock:
if integration_id not in _integration_stats:
_integration_stats[integration_id] = {
"event_count": 0,
"event_counts_by_type": {},
"last_event_time": None,
}
stats = _integration_stats[integration_id]
for event in events:
stats["event_count"] += 1
stats["event_counts_by_type"][event.event_type] = (
stats["event_counts_by_type"].get(event.event_type, 0) + 1
)
stats["last_event_time"] = event.timestamp
def _get_stats(integration_id: str) -> dict[str, Any]:
"""Get runtime stats for an integration."""
with _integration_state_lock:
return _integration_stats.get(
integration_id,
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
)
def _cleanup_state(integration_id: str) -> None:
"""Remove runtime state for a deleted integration."""
with _integration_state_lock:
_prev_states.pop(integration_id, None)
_integration_stats.pop(integration_id, None)
# ── Helper: convert config to response ────────────────────────────────────
def _redact_secrets(adapter_config: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of *adapter_config* with secret values masked.
The adapter ``auth_token`` is a live shared secret (it authenticates the
ingest endpoint). It is encrypted at rest, but the response builder echoes
the in-memory *decrypted* config, so without masking any API caller
(loopback-anonymous by default) could read the cleartext token. We never
return the secret over the API — the edit form submits a blank value to
keep the existing secret (see ``_merge_preserved_secrets``).
"""
cfg = dict(adapter_config)
for key in _SECRET_CONFIG_KEYS:
if cfg.get(key):
cfg[key] = "" # mask — never echo the secret to the client
return cfg
def _merge_preserved_secrets(
incoming: dict[str, Any] | None, existing: Any
) -> dict[str, Any] | None:
"""Preserve a stored secret when an update submits a blank/absent one.
Because the API masks secrets in responses, the edit form re-submits a
blank value for an unchanged secret. Without this merge that blank would
overwrite (and destroy) the stored token. A non-empty incoming value is a
deliberate change and is kept as-is.
"""
if incoming is None:
return None
merged = dict(incoming)
for key in _SECRET_CONFIG_KEYS:
if not merged.get(key) and existing.adapter_config.get(key):
merged[key] = existing.adapter_config[key]
return merged
def _config_to_response(config: Any) -> GameIntegrationResponse:
"""Convert a GameIntegrationConfig to its API response."""
"""Convert a GameIntegrationConfig to its API response (secrets redacted)."""
from ledgrab.api.schemas.game_integration import EventMappingSchema
return GameIntegrationResponse(
@@ -210,7 +201,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
name=config.name,
adapter_type=config.adapter_type,
enabled=config.enabled,
adapter_config=config.adapter_config,
adapter_config=_redact_secrets(config.adapter_config),
event_mappings=[
EventMappingSchema(
event_type=m.event_type,
@@ -302,6 +293,7 @@ async def create_integration(
data: GameIntegrationCreate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Create a new game integration config."""
try:
@@ -330,6 +322,8 @@ async def create_integration(
)
fire_entity_event("game_integration", "created", config.id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
return _config_to_response(config)
except EntityNotFoundError as e:
@@ -369,6 +363,7 @@ async def update_integration(
data: GameIntegrationUpdate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Update a game integration config."""
try:
@@ -386,12 +381,20 @@ async def update_integration(
for m in data.event_mappings
]
# Preserve a stored secret when the update submits a blank token
# (the API masks secrets, so the edit form re-sends a blank value
# for an unchanged secret — see _merge_preserved_secrets).
adapter_config = data.adapter_config
if adapter_config is not None:
existing = store.get_integration(integration_id)
adapter_config = _merge_preserved_secrets(adapter_config, existing)
config = store.update_integration(
integration_id=integration_id,
name=data.name,
adapter_type=data.adapter_type,
enabled=data.enabled,
adapter_config=data.adapter_config,
adapter_config=adapter_config,
event_mappings=mappings,
description=data.description,
tags=data.tags,
@@ -400,6 +403,8 @@ async def update_integration(
)
fire_entity_event("game_integration", "updated", integration_id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
return _config_to_response(config)
except EntityNotFoundError as e:
@@ -420,11 +425,14 @@ async def delete_integration(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
):
"""Delete a game integration config."""
try:
store.delete_integration(integration_id)
_cleanup_state(integration_id)
if lol_mgr is not None:
lol_mgr.sync(store.get_all_integrations())
fire_entity_event("game_integration", "deleted", integration_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -479,9 +487,17 @@ async def ingest_event(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Adapter-level auth check
# Adapter-level auth check. Treat ANY exception from validate_auth as an
# auth failure (rate-limited + 403), never a 500 — a malformed/attacker-
# controlled token must not crash the handler nor bypass the brute-force
# lockout counter.
headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
try:
authed = adapter_cls.validate_auth(headers, payload.data, config.adapter_config)
except Exception as exc:
logger.warning("validate_auth raised for %s: %s", integration_id, exc)
authed = False
if not authed:
_record_auth_failure(client_ip)
raise HTTPException(status_code=403, detail="Adapter authentication failed")
+35 -1
View File
@@ -36,7 +36,29 @@ class RuleSchema(BaseModel):
)
timezone: str | None = Field(
None,
description="IANA timezone for time_of_day rule (e.g. 'Europe/Berlin'). Empty = server local.",
description=(
"IANA timezone for time_of_day / solar rules (e.g. 'Europe/Berlin'). "
"Empty = server local."
),
)
# Solar rule fields (days_of_week / timezone above are shared with time_of_day)
start_event: str | None = Field(
None, description="'sunrise' or 'sunset' — window start anchor (for solar rule)"
)
start_offset_minutes: int | None = Field(
None, description="Minutes added to the start event, ±1439 (for solar rule)"
)
end_event: str | None = Field(
None, description="'sunrise' or 'sunset' — window end anchor (for solar rule)"
)
end_offset_minutes: int | None = Field(
None, description="Minutes added to the end event, ±1439 (for solar rule)"
)
latitude: float | None = Field(
None, description="Latitude for solar timing, -90..90 (for solar rule)"
)
longitude: float | None = Field(
None, description="Longitude for solar timing, -180..180 (for solar rule)"
)
# System idle rule fields
idle_minutes: int | None = Field(
@@ -179,3 +201,15 @@ class AutomationListResponse(BaseModel):
automations: List[AutomationResponse] = Field(description="List of automations")
count: int = Field(description="Number of automations")
class AutomationTriggerResponse(BaseModel):
"""Result of manually triggering an automation."""
status: str = Field(
description="'triggered' (scene applied / nothing to apply), 'partial' "
"(applied with errors), 'skipped' (rules not satisfied), or 'error'."
)
errors: List[str] = Field(
default_factory=list, description="Per-target error messages, if any."
)
@@ -145,6 +145,10 @@ class EffectCSSResponse(_CSSResponseBase):
scale: Any = Field(description="Spatial scale")
mirror: bool = Field(description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool = Field(False, description="Modulate output by live audio loudness")
reactive_audio_source_id: str = Field("", description="AudioSource id driving reactivity")
reactive_mode: str = Field("brightness", description="brightness | saturation | both")
reactive_intensity: Any = Field(None, description="Reactive modulation strength (0-1)")
class CompositeCSSResponse(_CSSResponseBase):
@@ -332,6 +336,10 @@ class EffectCSSCreate(_CSSCreateBase):
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
reactive_mode: str | None = Field(None, description="brightness | saturation | both")
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
class CompositeCSSCreate(_CSSCreateBase):
@@ -532,6 +540,10 @@ class EffectCSSUpdate(_CSSUpdateBase):
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
reactive_mode: str | None = Field(None, description="brightness | saturation | both")
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
class CompositeCSSUpdate(_CSSUpdateBase):
+17
View File
@@ -106,6 +106,9 @@ class DeviceCreate(BaseModel):
le=10000,
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
)
nanoleaf_per_panel: bool | None = Field(
None, description="Stream each panel individually via extControl UDP (vs single colour)"
)
# SPI Direct fields
spi_speed_hz: int | None = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
@@ -212,6 +215,9 @@ class DeviceUpdate(BaseModel):
nanoleaf_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
)
nanoleaf_per_panel: bool | None = Field(
None, description="Stream each panel individually via extControl UDP"
)
spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: str | None = Field(None, description="LED chipset type")
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
@@ -356,6 +362,14 @@ class Calibration(BaseModel):
roi_height: float = Field(
default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)"
)
linear_blend: bool = Field(
default=False,
description="Blend border pixels in linear light instead of sRGB (perceptually correct)",
)
dither: bool = Field(
default=False,
description="Spatio-temporally dither the final 8-bit output to reduce gradient banding",
)
class CalibrationTestModeRequest(BaseModel):
@@ -446,6 +460,9 @@ class DeviceResponse(BaseModel):
nanoleaf_min_interval_ms: int = Field(
default=100, description="Nanoleaf client-side rate limit in ms"
)
nanoleaf_per_panel: bool = Field(
default=False, description="Stream each panel individually via extControl UDP"
)
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
@@ -1,18 +1,31 @@
"""Actor context variable for the activity log.
``current_actor`` is set by ``api/auth.py:verify_api_key`` on every request so
that ``ActivityRecorder.record(...)`` can resolve the actor without requiring
every call site to pass it explicitly.
``current_actor`` is set by ``api/auth.py:verify_api_key`` so that
``ActivityRecorder.record(...)`` can resolve the actor without requiring every
call site to pass it explicitly.
Default value is ``"system"`` — used by background engines and any code path
that runs outside a request context (e.g. lifespan startup/shutdown, zeroconf
discovery thread).
Per-request isolation is guaranteed by ASGI's coroutine context: each request
runs in its own coroutine with its own copy of the context inherited from the
server's main task. The auth layer resets it on every request before the route
handler runs, so stale labels from a previous request cannot bleed into a new
one.
Per-request isolation is provided by ASGI/anyio ContextVar copy semantics:
Starlette dispatches each request in its own task whose context is a copy of
the parent, so a ``current_actor.set(...)`` in one request is never visible to
another request, and each request starts from the ``"system"`` default.
The auth layer only *sets* (never resets) the actor: ``verify_api_key`` calls
``current_actor.set(...)`` on the authenticated path and on the loopback-
anonymous path. It is an ``async`` dependency on purpose — an async dependency
runs in the same task/context as the route handler, so the ``set`` is visible
to ``record(...)`` (a sync dependency would set it in a throwaway threadpool
context that the handler never sees). Routes without the ``verify_api_key``
dependency (e.g. the unauthenticated ``POST /api/v1/webhooks/{token}``) never
set it and therefore record as ``"system"``.
There is intentionally no explicit per-request reset — do not rely on one. If
you run a recorder call in a worker thread that inherited a parent request's
context, pass an explicit ``actor=`` to ``record(...)`` rather than trusting
the ContextVar default.
"""
from contextvars import ContextVar
@@ -45,8 +45,14 @@ logger = get_logger(__name__)
def _new_id() -> str:
"""Generate a compact activity-log entry id: ``al_<8-hex-chars>``."""
return "al_" + uuid.uuid4().hex[:8]
"""Generate an activity-log entry id: ``al_<32-hex-chars>``.
Uses the full 128-bit uuid4 hex. The ``id`` column is ``UNIQUE`` and a
collision is silently dropped (best-effort recorder), so the entropy must
be high enough that a collision is astronomically unlikely even against the
full retention window (default 20k live rows).
"""
return "al_" + uuid.uuid4().hex
def entry_to_dict(entry: ActivityLogEntry) -> dict:
@@ -74,9 +74,17 @@ def sanitize_display(value: str | None, *, maxlen: int = 120) -> str:
# that may survive if isprintable ever changes in a future Python version).
cleaned = "".join(ch for ch in cleaned if ch not in _EXPLICIT_DROP)
# 4. Cap length.
# 4. Cap length. Guard the degenerate maxlen cases: ``cleaned[: maxlen - 1]``
# with maxlen <= 0 would slice from the END (keeping all-but-last char or
# a negative-index tail), violating the bounded-length contract.
if maxlen <= 0:
return ""
if len(cleaned) > maxlen:
# Reserve one character for the ellipsis so total length == maxlen.
cleaned = cleaned[: maxlen - 1] + ""
if maxlen == 1:
# No room for content + ellipsis; emit the ellipsis alone.
cleaned = ""
else:
# Reserve one character for the ellipsis so total length == maxlen.
cleaned = cleaned[: maxlen - 1] + ""
return cleaned
@@ -13,8 +13,10 @@ from ledgrab.storage.automation import (
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
ManualTriggerRule,
MQTTRule,
Rule,
SolarRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
@@ -23,6 +25,7 @@ from ledgrab.storage.automation import (
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset import ScenePreset
from ledgrab.utils import get_logger
from ledgrab.utils.solar import compute_solar_times, utc_offset_hours_for
logger = get_logger(__name__)
@@ -141,6 +144,11 @@ class AutomationEngine:
self._last_deactivated: Dict[str, datetime] = {}
# webhook_token → bool (volatile state set by webhook calls)
self._webhook_states: Dict[str, bool] = {}
# True only while a single automation is being manually fired
# (fire_manual_trigger). The background tick never sets it, so a
# ManualTriggerRule reads False during normal evaluation and a
# manual-trigger automation never activates on its own.
self._manual_fire_active: bool = False
# HA source IDs currently acquired by the engine
self._ha_acquired: Set[str] = set()
# MQTT source IDs currently acquired by the engine
@@ -369,6 +377,32 @@ class AutomationEngine:
display_state,
)
@staticmethod
def _detection_needs(rules) -> tuple[bool, bool, bool, bool, bool]:
"""Which platform-detection probes a set of rules requires.
Returns ``(needs_running, needs_topmost, needs_fullscreen, needs_idle,
needs_display_state)``. Shared by the background evaluation tick and the
one-shot manual-trigger path so both request the same detection set.
"""
match_types_used: set = set()
needs_idle = False
needs_display_state = False
for r in rules:
if isinstance(r, ApplicationRule):
match_types_used.add(r.match_type)
elif isinstance(r, SystemIdleRule):
needs_idle = True
elif isinstance(r, DisplayStateRule):
needs_display_state = True
return (
"running" in match_types_used,
bool(match_types_used & {"topmost", "topmost_fullscreen"}),
"fullscreen" in match_types_used,
needs_idle,
needs_display_state,
)
async def _evaluate_all_locked(self) -> None:
automations = self._store.get_all_automations()
if not automations:
@@ -377,23 +411,15 @@ class AutomationEngine:
await self._deactivate_automation(aid)
return
# Determine which detection methods are actually needed
match_types_used: set = set()
needs_idle = False
needs_display_state = False
for a in automations:
if a.enabled:
for r in a.rules:
if isinstance(r, ApplicationRule):
match_types_used.add(r.match_type)
elif isinstance(r, SystemIdleRule):
needs_idle = True
elif isinstance(r, DisplayStateRule):
needs_display_state = True
needs_running = "running" in match_types_used
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
needs_fullscreen = "fullscreen" in match_types_used
# Determine which detection methods are actually needed (across the
# rules of every *enabled* automation — disabled ones are skipped below).
(
needs_running,
needs_topmost,
needs_fullscreen,
needs_idle,
needs_display_state,
) = self._detection_needs([r for a in automations if a.enabled for r in a.rules])
# Single executor call for all platform detection
(
@@ -526,6 +552,9 @@ class AutomationEngine:
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_time_of_day(rule)
def _handle_solar(self, rule: SolarRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_solar(rule)
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_idle(rule, ctx.idle_seconds)
@@ -538,12 +567,40 @@ class AutomationEngine:
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
return self._webhook_states.get(rule.token, False)
def _handle_manual(self, rule: ManualTriggerRule, ctx: _RuleEvalContext) -> bool:
# True only while fire_manual_trigger is evaluating this one automation
# under the eval lock; always False during the background tick.
return self._manual_fire_active
def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_home_assistant(rule)
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_http_poll(rule)
@staticmethod
def _weekday_window_active(
current: int, start: int, end: int, weekday: int, days: list
) -> bool:
"""Is ``current`` (minutes-of-day) inside the [start, end] window?
Handles the overnight wrap (start > end): the after-midnight tail
belongs to the window's START day, so it's matched against the
previous weekday. ``days`` empty = every day of the week.
"""
if start <= end:
if not (start <= current <= end):
return False
return not days or weekday in days
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its
# START day, so the after-midnight tail is matched against yesterday.
if current >= start: # evening portion — today's window
return not days or weekday in days
if current <= end: # early-morning portion — yesterday's window
return not days or ((weekday - 1) % 7) in days
return False
@staticmethod
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
now = _now_in_tz(rule.timezone)
@@ -552,20 +609,34 @@ class AutomationEngine:
parts_e = rule.end_time.split(":")
start = int(parts_s[0]) * 60 + int(parts_s[1])
end = int(parts_e[0]) * 60 + int(parts_e[1])
days = rule.days_of_week
return AutomationEngine._weekday_window_active(
current, start, end, now.weekday(), rule.days_of_week
)
if start <= end:
if not (start <= current <= end):
return False
return not days or now.weekday() in days
@staticmethod
def _evaluate_solar(rule: SolarRule) -> bool:
# One ``now`` drives every read: day-of-year, the UTC offset for the
# solar math, the current-minute compare, and the weekday.
now = _now_in_tz(rule.timezone)
day_of_year = now.timetuple().tm_yday
utc_offset = utc_offset_hours_for(rule.timezone, now)
sunrise_h, sunset_h = compute_solar_times(
rule.latitude, rule.longitude, day_of_year, utc_offset
)
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its
# START day, so the after-midnight tail is matched against yesterday.
if current >= start: # evening portion — today's window
return not days or now.weekday() in days
if current <= end: # early-morning portion — yesterday's window
return not days or ((now.weekday() - 1) % 7) in days
return False
def _event_minutes(event: str) -> int:
hour = sunset_h if event == "sunset" else sunrise_h
return int(round(hour * 60))
# compute_solar_times clamps sunrise < sunset, so the only way to wrap
# past midnight is via the offsets — which ``_weekday_window_active``
# handles the same way it does an overnight time-of-day window.
start = (_event_minutes(rule.start_event) + rule.start_offset_minutes) % 1440
end = (_event_minutes(rule.end_event) + rule.end_offset_minutes) % 1440
current = now.hour * 60 + now.minute
return AutomationEngine._weekday_window_active(
current, start, end, now.weekday(), rule.days_of_week
)
@staticmethod
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
@@ -675,6 +746,62 @@ class AutomationEngine:
# Default: "running"
return any(app in running_procs for app in apps_lower)
def _audit_activation(self, automation: Automation) -> None:
"""Best-effort audit record for any successful automation activation.
Shared by both the normal scene path and the no-scene branch so an
activation is recorded uniformly regardless of whether a scene was
applied (mirrors the uniform recording on the deactivation side).
"""
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_name = sanitize_display(automation.name) if automation.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.activated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' activated",
)
except Exception:
pass
def _audit_manual_trigger(self, automation: Automation) -> None:
"""Best-effort audit record for a manual trigger.
Unlike :meth:`_audit_activation` this does NOT force ``actor='system'``
— the recorder resolves ``actor`` from the ``current_actor`` ContextVar
(set in ``verify_api_key``), so the run is attributed to the user who
pressed the button.
"""
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_name = sanitize_display(automation.name) if automation.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.triggered",
severity=ActivitySeverity.INFO,
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' manually triggered",
)
except Exception:
pass
async def _activate_automation(self, automation: Automation) -> None:
if not automation.scene_preset_id:
# No scene configured — just mark active (rules matched but nothing to do)
@@ -682,6 +809,9 @@ class AutomationEngine:
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "activated")
logger.info(f"Automation '{automation.name}' activated (no scene configured)")
# Record the activation too — a no-scene activation is still a
# successful activation and must appear in the audit log.
self._audit_activation(automation)
return
if not self._scene_preset_store or not self._target_store or not self._device_store:
@@ -726,27 +856,86 @@ class AutomationEngine:
else:
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
# Audit record — best-effort.
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
# Audit record — best-effort (shared helper, also used by no-scene path).
self._audit_activation(automation)
rec = get_module_recorder()
if rec is not None:
_safe_name = sanitize_display(automation.name) if automation.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.activated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' activated",
)
except Exception:
pass
async def _apply_manual_scene(self, automation: Automation) -> tuple[str, list[str]]:
"""Apply the automation's scene once for a manual trigger.
Mirrors the scene-application core of :meth:`_activate_automation` but
does NOT enter the sticky ``_active_automations`` state or capture a
revert snapshot — a manual trigger is a one-shot apply, so the
background tick has nothing to reconcile away. Returns
``(status, errors)`` where ``status`` is ``"triggered"`` (applied, or no
scene configured), ``"partial"`` (applied with errors), or ``"error"``
(scene stores unavailable / preset missing).
"""
if not automation.scene_preset_id:
return ("triggered", [])
if not self._scene_preset_store or not self._target_store or not self._device_store:
logger.warning(
f"Automation '{automation.name}' triggered but scene stores not available"
)
return ("error", ["scene stores not available"])
try:
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
except ValueError:
logger.warning(
f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found"
)
return ("error", [f"scene preset {automation.scene_preset_id} not found"])
from ledgrab.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state(preset, self._target_store, self._manager)
if errors:
logger.warning(
f"Automation '{automation.name}' manually triggered with errors: {errors}"
)
else:
logger.info(
f"Automation '{automation.name}' manually triggered (scene '{preset.name}' applied)"
)
# apply_scene_state returns "activated"/"partial"; surface "triggered"
# for the happy path so the API status reads naturally.
return ("triggered" if status == "activated" else status, errors)
async def fire_manual_trigger(self, automation: Automation) -> tuple[str, list[str]]:
"""Manually fire an automation: evaluate its rules with the manual
trigger satisfied and, if it should activate, apply its scene once.
"Checks all of the rules": the automation's full rule set is evaluated
under its ``rule_logic`` with the ManualTriggerRule treated as True. The
``enabled`` flag is intentionally ignored — it gates only the background
tick; a manual trigger is an explicit user action. Returns
``(status, errors)``: ``"skipped"`` when the rules are not satisfied,
otherwise the result of :meth:`_apply_manual_scene`.
"""
async with self._eval_lock:
detection = await asyncio.to_thread(
self._detect_all_sync, *self._detection_needs(automation.rules)
)
# Force the manual term True for this one evaluation, then clear it
# before releasing the lock so the background tick never sees it.
self._manual_fire_active = True
try:
should_fire = (not automation.rules) or self._evaluate_rules(automation, *detection)
finally:
self._manual_fire_active = False
if not should_fire:
logger.info(
f"Automation '{automation.name}' manual trigger skipped (rules not satisfied)"
)
return ("skipped", [])
status, errors = await self._apply_manual_scene(automation)
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "triggered")
self._audit_manual_trigger(automation)
return (status, errors)
async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_automations.pop(automation_id, False)
@@ -781,11 +970,9 @@ class AutomationEngine:
rec = get_module_recorder()
if rec is not None:
_auto_name: str | None = None
try:
_auto_name = self._store.get_automation(automation_id).name
except Exception:
pass
# Reuse the automation already fetched above (no second store
# read); degrades to None if it was since-deleted (== None).
_auto_name = automation.name if automation else None
_safe_deact_name = sanitize_display(_auto_name) if _auto_name else None
rec.record(
category=ActivityCategory.CAPTURE,
@@ -904,10 +1091,12 @@ AutomationEngine._RULE_HANDLERS = {
StartupRule: AutomationEngine._handle_startup,
ApplicationRule: AutomationEngine._handle_application,
TimeOfDayRule: AutomationEngine._handle_time_of_day,
SolarRule: AutomationEngine._handle_solar,
SystemIdleRule: AutomationEngine._handle_system_idle,
DisplayStateRule: AutomationEngine._handle_display_state,
MQTTRule: AutomationEngine._handle_mqtt,
WebhookRule: AutomationEngine._handle_webhook,
ManualTriggerRule: AutomationEngine._handle_manual,
HomeAssistantRule: AutomationEngine._handle_home_assistant,
HTTPPollRule: AutomationEngine._handle_http_poll,
}
@@ -925,10 +1114,12 @@ def _assert_rule_handler_coverage() -> None:
StartupRule,
ApplicationRule,
TimeOfDayRule,
SolarRule,
SystemIdleRule,
DisplayStateRule,
MQTTRule,
WebhookRule,
ManualTriggerRule,
HomeAssistantRule,
HTTPPollRule,
}
+42 -2
View File
@@ -120,6 +120,11 @@ class CalibrationConfig:
roi_y: float = 0.0
roi_width: float = 1.0
roi_height: float = 1.0
# Blend border pixels in linear light (perceptually correct averaging)
# instead of gamma-encoded sRGB. Off by default = unchanged behaviour.
linear_blend: bool = False
# Spatio-temporal dither the final 8-bit quantization to reduce banding.
dither: bool = False
@property
def has_roi(self) -> bool:
@@ -349,6 +354,8 @@ class PixelMapper:
"""
self.calibration = calibration
self.interpolation_mode = interpolation_mode
# Per-frame counter driving the temporal dither phase.
self._dither_frame = 0
# Validate calibration
self.calibration.validate()
@@ -430,7 +437,16 @@ class PixelMapper:
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
the shared kernel handles all allocations on first use.
"""
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, edge_name)
return average_edge_to_leds(
edge_pixels,
edge_name,
led_count,
self._edge_cache,
edge_name,
linear=self.calibration.linear_blend,
dither=self.calibration.dither,
frame_index=self._dither_frame,
)
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors.
@@ -449,6 +465,7 @@ class PixelMapper:
"""
led_array = self._led_buf
led_array[:] = 0
self._dither_frame += 1
# Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll)
for i, segment in enumerate(self.calibration.segments):
@@ -514,6 +531,7 @@ class AdvancedPixelMapper:
):
self.calibration = calibration
self.interpolation_mode = interpolation_mode
self._dither_frame = 0
calibration.validate()
if interpolation_mode == "average":
@@ -600,7 +618,16 @@ class AdvancedPixelMapper:
``cache_key`` is an integer (e.g. line index) so multiple per-line
edges can share the same ``self._edge_cache`` dict without colliding.
"""
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, cache_key)
return average_edge_to_leds(
edge_pixels,
edge_name,
led_count,
self._edge_cache,
cache_key,
linear=self.calibration.linear_blend,
dither=self.calibration.dither,
frame_index=self._dither_frame,
)
def _map_edge_fallback(
self,
@@ -622,6 +649,7 @@ class AdvancedPixelMapper:
"""
led_array = self._led_buf
led_array[:] = 0
self._dither_frame += 1
for i, line in enumerate(self.calibration.lines):
frame = frames.get(line.picture_source_id)
@@ -902,6 +930,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
offset=data.get("offset", 0),
skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0),
linear_blend=bool(data.get("linear_blend", False)),
dither=bool(data.get("dither", False)),
)
config.validate()
return config
@@ -931,6 +961,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
roi_y=data.get("roi_y", 0.0),
roi_width=data.get("roi_width", 1.0),
roi_height=data.get("roi_height", 1.0),
linear_blend=bool(data.get("linear_blend", False)),
dither=bool(data.get("dither", False)),
)
config.validate()
@@ -975,6 +1007,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["skip_leds_start"] = config.skip_leds_start
if config.skip_leds_end > 0:
result["skip_leds_end"] = config.skip_leds_end
if config.linear_blend:
result["linear_blend"] = True
if config.dither:
result["dither"] = True
return result
# Simple mode
@@ -1008,4 +1044,8 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["roi_y"] = config.roi_y
result["roi_width"] = config.roi_width
result["roi_height"] = config.roi_height
if config.linear_blend:
result["linear_blend"] = True
if config.dither:
result["dither"] = True
return result
@@ -233,8 +233,20 @@ class CalibrationSession:
self._last_activity = datetime.now(timezone.utc)
self._active = True
# Clear the device to black so the chase starts from a clean state
await manager.send_clear_pixels(device_id)
# Clear the device to black so the chase starts from a clean state.
# send_clear_pixels re-raises on a double send failure; a transient
# failure here must NOT strand the session with _active=True and no
# watchdog — log and continue so the idle-timeout watchdog still gets
# armed (mirrors the guarded clear in _teardown_locked).
try:
await manager.send_clear_pixels(device_id)
except Exception as exc:
logger.warning(
"CalibrationSession.start: failed to clear pixels on %s "
"before chase (continuing): %s",
device_id,
exc,
)
# Start idle-timeout watchdog
self._timeout_task = asyncio.ensure_future(self._idle_watchdog())
@@ -23,6 +23,9 @@ from typing import Any, Callable, Dict, Hashable, Tuple
import numpy as np
from ledgrab.utils.dither import ordered_dither_quantize
from ledgrab.utils.linear_light import linear_to_srgb_float, linear_to_srgb_uint8, srgb_to_linear
# Cache value layout — kept as a tuple for the small per-frame cost of
# tuple unpacking vs the readability of a dataclass. The first two entries
# are the (edge_len, led_count) signature used to detect a re-build.
@@ -75,6 +78,9 @@ def average_edge_to_leds(
led_count: int,
cache: Dict[Hashable, _CacheEntry],
cache_key: Hashable,
linear: bool = False,
dither: bool = False,
frame_index: int = 0,
) -> np.ndarray:
"""Vectorised average colour per LED segment.
@@ -82,6 +88,14 @@ def average_edge_to_leds(
over axis=0 (collapsing rows), then segment along the width; for
left/right edges we average over axis=1 then segment along the height.
When ``linear`` is True the pixels are decoded to linear light before
averaging and re-encoded to sRGB at the end — perceptually correct
blending at a small extra cost (a LUT decode of the input + an analytic
encode of the per-LED result).
When ``dither`` is True the final 8-bit quantization is spatio-temporally
dithered (using ``frame_index``) to suppress gradient banding.
Returns a view into the caller-owned cache's ``out_uint8`` buffer —
do NOT retain the result across calls without copying.
"""
@@ -110,8 +124,13 @@ def average_edge_to_leds(
out_uint8,
) = entry
# Decode to linear light first so both the row/column collapse and the
# per-segment mean happen in physically-linear space. ``src`` is float32
# in [0, 1] (linear) or the raw uint8 sRGB pixels otherwise.
src = srgb_to_linear(edge_pixels) if linear else edge_pixels
# Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
np.mean(src, axis=axis, out=edge_1d_buf)
# Cumulative sum so each LED segment's sum is two array lookups apart.
cumsum_buf[0] = 0
@@ -122,8 +141,16 @@ def average_edge_to_leds(
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
if dither:
# sums_buf is linear [0,1] or sRGB [0,255]; quantize with dithering.
srgb_f = linear_to_srgb_float(sums_buf) if linear else sums_buf
np.copyto(out_uint8, ordered_dither_quantize(srgb_f, frame_index))
elif linear:
# sums_buf holds linear [0, 1] averages — re-encode to sRGB uint8.
np.copyto(out_uint8, linear_to_srgb_uint8(sums_buf))
else:
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
@@ -19,6 +19,17 @@ logger = get_logger(__name__)
ARDUINO_RESET_DELAY = 2.0 # seconds to wait after opening serial for Arduino bootloader
# Settle time between sending the final black frame and closing the port.
# Closing the serial port deasserts DTR, which triggers the Arduino
# auto-reset on most Adalight boards. ``flush()`` only guarantees the bytes
# left the host UART — NOT that the board received the frame, parsed it, and
# ran the LED ``show()`` before the reset wipes its RAM. Without this pause
# the reset intermittently wins the race and the strip latches its last lit
# frame instead of going dark (observed as "the strip sometimes stays on
# after the target/automation stops"). 150 ms is far more than the few ms a
# board needs to paint one frame, and it's a one-shot cost on teardown.
BLACK_FRAME_SETTLE_DELAY = 0.15
def parse_adalight_url(url: str) -> Tuple[str, int]:
"""Backwards-compatible alias for :func:`parse_serial_url`."""
@@ -126,6 +137,10 @@ class AdalightClient(LEDClient):
)
await loop.run_in_executor(executor, self._serial.write, frame)
await loop.run_in_executor(executor, self._serial.flush)
# Let the board parse the frame and run show() before close()
# toggles DTR and resets it — otherwise the strip can latch its
# last lit frame instead of going dark. See BLACK_FRAME_SETTLE_DELAY.
await asyncio.sleep(BLACK_FRAME_SETTLE_DELAY)
logger.info(f"Adalight black frame sent and flushed: {self._port}")
except Exception as e:
logger.warning(f"Failed to send black frame on close: {e}")
@@ -153,6 +153,8 @@ class NanoleafConfig(BaseDeviceConfig):
device_type: Literal["nanoleaf"] = "nanoleaf"
nanoleaf_token: str = ""
nanoleaf_min_interval_ms: int = 100
# Per-panel extControl UDP streaming (addresses each panel) vs single colour.
nanoleaf_per_panel: bool = False
@dataclass(frozen=True)
@@ -6,12 +6,13 @@ handshake: the user holds the controller's power button for 5 seconds to
open a 30-second pairing window, then we POST to ``/api/v1/new`` to claim
an auth token. The token is long-lived and gets stored on the device.
Once paired, color control is a simple ``PUT /api/v1/{token}/state`` with
HSBT (hue / saturation / brightness; kelvin only matters when sat=0).
LedGrab averages the incoming strip to one HSB triple. Per-panel streaming
mode (``extControl`` UDP, ~60 Hz, addresses each panel individually) is
documented but not implemented here — the MVP keeps the device acting as
a single-pixel target like Yeelight / Hue.
Two output modes:
* **Single-colour** (default): average the strip to one HSB triple and
``PUT /api/v1/{token}/state`` — matches every other consumer-bulb driver.
* **Per-panel** (``per_panel=True``): enable ``extControl`` v2 and stream a
UDP packet per frame to port 60222, addressing each panel individually
(resampled from the strip). Enabled when the device config opts in; falls
back to single-colour if the controller/firmware can't stream.
URL scheme: ``nanoleaf://<host>``. Port is fixed at 16021 on the protocol
side. The auth token is stored separately on the device config, not in
@@ -23,6 +24,8 @@ Reference: https://forum.nanoleaf.me/docs/openapi
from __future__ import annotations
import asyncio
import socket
import struct
from datetime import datetime, timezone
from typing import List, Tuple
from urllib.parse import urlparse
@@ -37,9 +40,62 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__)
NANOLEAF_PORT = 16021
# extControl v2 UDP streaming target port (fixed by the protocol).
NANOLEAF_STREAM_PORT = 60222
DEFAULT_MIN_INTERVAL_S = 0.1 # 10 Hz; HTTP per frame, plenty for averaged ambilight
def order_panels(position_data: List[dict]) -> List[int]:
"""Order panel IDs left-to-right, top-to-bottom from ``panelLayout`` data.
Each ``positionData`` entry has ``panelId``/``x``/``y``. We sort by (x, y)
so an ambient strip maps across the panels spatially. Entries without an
integer ``panelId`` (or the controller/rhythm panel, panelId 0) are dropped.
"""
panels = [
p for p in position_data if isinstance(p.get("panelId"), int) and p.get("panelId", 0) != 0
]
panels.sort(key=lambda p: (p.get("x", 0), p.get("y", 0)))
return [int(p["panelId"]) for p in panels]
def map_pixels_to_panels(
pixels: List[Tuple[int, int, int]] | np.ndarray,
panel_ids: List[int],
) -> List[Tuple[int, int, int, int]]:
"""Resample an N-pixel strip to one ``(panel_id, r, g, b)`` per panel.
Nearest-neighbour resample: panel ``i`` of ``M`` samples strip pixel
``floor(i * N / M)``. Returns black for an empty strip.
"""
arr = np.asarray(pixels, dtype=np.uint8).reshape(-1, 3)
n_pix = len(arr)
n_panels = max(1, len(panel_ids))
out: List[Tuple[int, int, int, int]] = []
for i, pid in enumerate(panel_ids):
if n_pix == 0:
out.append((pid, 0, 0, 0))
continue
idx = min(n_pix - 1, (i * n_pix) // n_panels)
px = arr[idx]
out.append((pid, int(px[0]), int(px[1]), int(px[2])))
return out
def build_extcontrol_v2_packet(panels: List[Tuple[int, int, int, int]]) -> bytes:
"""Build a Nanoleaf extControl **v2** UDP streaming packet.
Wire format (all multi-byte fields big-endian):
``uint16 nPanels`` then per panel
``uint16 panelId, uint8 R, uint8 G, uint8 B, uint8 W, uint16 transitionTime``
``transitionTime`` is in 100 ms units; 1 = a 100 ms ease for smooth motion.
"""
parts = [struct.pack(">H", len(panels))]
for pid, r, g, b in panels:
parts.append(struct.pack(">HBBBBH", pid & 0xFFFF, r & 0xFF, g & 0xFF, b & 0xFF, 0, 1))
return b"".join(parts)
def parse_nanoleaf_url(url: str) -> str:
"""Pull the host out of ``nanoleaf://host`` or accept a bare host.
@@ -131,6 +187,7 @@ class NanoleafClient(LEDClient):
auth_token: str = "",
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
request_timeout_s: float = 3.0,
per_panel: bool = False,
):
self._host = parse_nanoleaf_url(url)
self._token = auth_token
@@ -140,6 +197,11 @@ class NanoleafClient(LEDClient):
self._http: httpx.AsyncClient | None = None
self._connected = False
self._next_tx_at: float = 0.0
# Per-panel extControl streaming state.
self._per_panel = per_panel
self._streaming = False
self._panel_ids: List[int] = []
self._udp: socket.socket | None = None
@property
def host(self) -> str:
@@ -157,6 +219,9 @@ class NanoleafClient(LEDClient):
@property
def device_led_count(self) -> int | None:
# In per-panel streaming mode the panel count is authoritative.
if self._streaming and self._panel_ids:
return len(self._panel_ids)
return self._led_count or None
def _state_url(self) -> str:
@@ -169,9 +234,63 @@ class NanoleafClient(LEDClient):
raise RuntimeError("NanoleafClient requires an auth_token; pair the device first")
self._http = httpx.AsyncClient(timeout=self._request_timeout_s)
self._connected = True
logger.info("NanoleafClient connected to %s:%d", self._host, NANOLEAF_PORT)
if self._per_panel:
# Best-effort: if streaming can't be enabled (old firmware, network)
# we silently fall back to the single-colour HTTP path.
try:
await self._setup_streaming()
except Exception as exc: # noqa: BLE001 — degrade, never fail connect
logger.warning(
"Nanoleaf %s: per-panel streaming unavailable, using single-colour (%s)",
self._host,
exc,
)
self._streaming = False
logger.info(
"NanoleafClient connected to %s:%d (per_panel=%s, streaming=%s)",
self._host,
NANOLEAF_PORT,
self._per_panel,
self._streaming,
)
return True
async def _setup_streaming(self) -> None:
"""Fetch the panel layout and enable extControl v2 UDP streaming."""
if self._http is None:
raise RuntimeError("NanoleafClient not connected")
base = f"http://{self._host}:{NANOLEAF_PORT}/api/v1/{self._token}"
# 1) Panel layout → ordered panel IDs.
resp = await self._http.get(f"{base}/panelLayout/layout")
resp.raise_for_status()
position_data = resp.json().get("positionData", [])
panel_ids = order_panels(position_data)
if not panel_ids:
raise RuntimeError("Nanoleaf reported no addressable panels")
# 2) Switch the controller into external (UDP) control, v2.
enable = await self._http.put(
f"{base}/effects",
json={
"write": {
"command": "display",
"animType": "extControl",
"extControlVersion": "v2",
}
},
)
if enable.status_code not in (200, 204):
raise RuntimeError(
f"Nanoleaf refused extControl enable ({enable.status_code}): {enable.text[:200]}"
)
# 3) Fire-and-forget UDP socket for the per-frame stream.
self._udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._udp.setblocking(False)
self._panel_ids = panel_ids
self._streaming = True
logger.info(
"Nanoleaf %s: extControl v2 streaming over %d panels", self._host, len(panel_ids)
)
async def close(self) -> None:
if self._http is not None:
try:
@@ -179,6 +298,13 @@ class NanoleafClient(LEDClient):
except (httpx.HTTPError, RuntimeError):
pass
self._http = None
if self._udp is not None:
try:
self._udp.close()
except OSError:
pass
self._udp = None
self._streaming = False
self._connected = False
async def _put_state(self, body: dict) -> None:
@@ -197,12 +323,28 @@ class NanoleafClient(LEDClient):
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip and PUT a single HSB state update."""
"""Stream per-panel (extControl) when enabled, else average to one HSB state."""
if not self.is_connected:
raise RuntimeError("NanoleafClient not connected")
loop_now = asyncio.get_event_loop().time()
if loop_now < self._next_tx_at:
return True
if self._streaming and self._udp is not None and self._panel_ids:
scale = max(0, min(255, brightness)) / 255.0 if brightness < 255 else 1.0
arr = np.asarray(pixels, dtype=np.float32).reshape(-1, 3)
if scale != 1.0:
arr = arr * scale
scaled = np.clip(arr, 0, 255).astype(np.uint8)
panels = map_pixels_to_panels(scaled, self._panel_ids)
packet = build_extcontrol_v2_packet(panels)
try:
self._udp.sendto(packet, (self._host, NANOLEAF_STREAM_PORT))
except OSError as exc:
logger.debug("Nanoleaf %s: UDP stream send failed (%s)", self._host, exc)
self._next_tx_at = loop_now + self._min_interval_s
return True
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
@@ -61,6 +61,7 @@ class NanoleafDeviceProvider(LEDDeviceProvider):
led_count=config.led_count,
auth_token=config.nanoleaf_token,
min_interval_s=max(0.0, config.nanoleaf_min_interval_ms / 1000.0),
per_panel=getattr(config, "nanoleaf_per_panel", False),
)
async def pair_device(self, url: str) -> dict:
@@ -262,8 +262,10 @@ class CS2Adapter(GameAdapter):
actual_token = auth_section.get("token", "")
if not actual_token:
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_token, expected_token)
# Constant-time comparison to avoid a timing oracle. Compare UTF-8 bytes
# so an attacker-controlled non-ASCII payload token returns False rather
# than raising TypeError out of secrets.compare_digest (→ 500).
return secrets.compare_digest(actual_token.encode("utf-8"), expected_token.encode("utf-8"))
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -179,8 +179,10 @@ class Dota2Adapter(GameAdapter):
actual_token = auth_section.get("token", "")
if not actual_token:
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_token, expected_token)
# Constant-time comparison to avoid a timing oracle. Compare UTF-8 bytes
# so an attacker-controlled non-ASCII payload token returns False rather
# than raising TypeError out of secrets.compare_digest (→ 500).
return secrets.compare_digest(actual_token.encode("utf-8"), expected_token.encode("utf-8"))
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -79,7 +79,12 @@ class GenericWebhookAdapter(GameAdapter):
return False
# Constant-time comparison to avoid a token-length/timing oracle.
return secrets.compare_digest(actual_value, expected_token)
# Compare UTF-8 byte encodings: secrets.compare_digest raises TypeError
# on non-ASCII str, and the header value is attacker-controlled
# (Starlette latin-1-decodes header bytes to a possibly-non-ASCII str).
# Byte comparison is well-defined for any input and stays constant-time,
# so a non-ASCII token cleanly returns False instead of raising a 500.
return secrets.compare_digest(actual_value.encode("utf-8"), expected_token.encode("utf-8"))
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -0,0 +1,90 @@
"""Runtime manager that owns LoL Live-Client-Data polling threads.
Unlike GSI adapters (CS2, Dota 2) that push events into the HTTP ingest
endpoint, League of Legends only exposes a *local* poll API. The
:class:`~ledgrab.core.game_integration.adapters.lol_adapter.LoLPoller` knows how
to poll it; this manager owns the poller lifecycle, starting one daemon poller
per enabled ``lol`` integration and reconciling on config changes.
``sync()`` is the single reconciliation entry point — call it at startup and
after any integration create/update/delete so runtime pollers always match the
enabled ``lol`` integrations in the store.
"""
from __future__ import annotations
import threading
from typing import Any, Iterable
from ledgrab.core.game_integration.adapters.lol_adapter import LoLAdapter, LoLPoller
from ledgrab.core.game_integration.runtime_state import process_payload
from ledgrab.utils import get_logger
logger = get_logger(__name__)
class LoLPollManager:
"""Owns one :class:`LoLPoller` per enabled League of Legends integration."""
def __init__(self, event_bus: Any) -> None:
self._event_bus = event_bus
self._lock = threading.Lock()
self._pollers: dict[str, LoLPoller] = {}
# Last adapter_config a poller was started with, so a config edit
# (e.g. poll interval) triggers a restart rather than going unnoticed.
self._configs: dict[str, dict[str, Any]] = {}
def sync(self, integrations: Iterable[Any]) -> None:
"""Reconcile running pollers against the enabled ``lol`` integrations."""
desired = {
c.id: c
for c in integrations
if getattr(c, "enabled", False)
and getattr(c, "adapter_type", None) == LoLAdapter.ADAPTER_TYPE
}
with self._lock:
# Stop pollers whose integration is gone, disabled, or reconfigured.
for integration_id in list(self._pollers):
cfg = desired.get(integration_id)
if cfg is None or self._configs.get(integration_id) != dict(cfg.adapter_config):
self._stop_locked(integration_id)
# Start pollers for anything enabled that isn't already running.
for integration_id, cfg in desired.items():
if integration_id not in self._pollers:
self._start_locked(integration_id, cfg)
def stop_all(self) -> None:
"""Stop every poller (shutdown hook)."""
with self._lock:
for integration_id in list(self._pollers):
self._stop_locked(integration_id)
@property
def active_count(self) -> int:
with self._lock:
return len(self._pollers)
# ── internals (call under self._lock) ──────────────────────────────────
def _start_locked(self, integration_id: str, cfg: Any) -> None:
adapter_config = dict(cfg.adapter_config)
poller = LoLPoller(adapter_config, self._make_callback(integration_id, adapter_config))
poller.start()
self._pollers[integration_id] = poller
self._configs[integration_id] = adapter_config
logger.info("Started LoL poller for integration %s", integration_id)
def _stop_locked(self, integration_id: str) -> None:
poller = self._pollers.pop(integration_id, None)
self._configs.pop(integration_id, None)
if poller is not None:
poller.stop()
logger.info("Stopped LoL poller for integration %s", integration_id)
def _make_callback(self, integration_id: str, adapter_config: dict[str, Any]):
event_bus = self._event_bus
def _on_poll(data: dict[str, Any]) -> None:
process_payload(integration_id, LoLAdapter, adapter_config, data, event_bus)
return _on_poll
@@ -244,8 +244,12 @@ class MappingAdapter(GameAdapter):
actual_value = headers.get(header_name, "")
if not (expected_value and actual_value):
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_value, expected_value)
# Constant-time comparison to avoid a timing oracle. Compare UTF-8
# bytes so an attacker-controlled non-ASCII header value returns
# False rather than raising TypeError out of compare_digest (→ 500).
return secrets.compare_digest(
actual_value.encode("utf-8"), expected_value.encode("utf-8")
)
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
return False
@@ -0,0 +1,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,115 @@
"""AudioEnergyTap — a lightweight read-only tap on a shared audio capture.
Lets a non-audio stream (e.g. a procedural effect) react to live loudness
without owning audio capture. It resolves an ``audio_source_id`` to capture
params exactly like ``AudioColorStripStream`` does, acquires the SHARED stream
via the ``AudioCaptureManager`` (ref-counted, so it piggybacks on any audio
visualiser already capturing the same device), and exposes a smoothed 01
energy scalar.
"""
from __future__ import annotations
from typing import Any
from ledgrab.utils import get_logger
logger = get_logger(__name__)
# RMS of typical program audio sits around 0.050.3; scale so a moderately
# loud signal reaches ~1.0 before the per-effect intensity slider is applied.
_RMS_GAIN = 4.0
class AudioEnergyTap:
"""Resolve → acquire → read smoothed loudness from a shared audio capture."""
def __init__(
self,
audio_capture_manager: Any,
audio_source_store: Any = None,
audio_template_store: Any = None,
) -> None:
self._mgr = audio_capture_manager
self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store
self._stream = None
self._source_id = ""
self._device_index = -1
self._loopback = True
self._engine_type = None
self._engine_config = None
self._smoothed = 0.0
@property
def available(self) -> bool:
return self._mgr is not None
@property
def active(self) -> bool:
return self._stream is not None
def configure(self, audio_source_id: str) -> None:
"""Resolve capture params for ``audio_source_id`` (no acquire yet)."""
self._source_id = audio_source_id or ""
self._device_index = -1
self._loopback = True
self._engine_type = None
self._engine_config = None
if not self._source_id or not self._audio_source_store:
return
try:
resolved = self._audio_source_store.resolve_audio_source(self._source_id)
self._device_index = resolved.device_index
self._loopback = resolved.is_loopback
if resolved.audio_template_id and self._audio_template_store:
try:
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
self._engine_type = tpl.engine_type
self._engine_config = tpl.engine_config
except ValueError as e:
logger.warning(
"AudioEnergyTap: template %s missing: %s", resolved.audio_template_id, e
)
except ValueError as e:
logger.warning(
"AudioEnergyTap: failed to resolve audio source %s: %s", self._source_id, e
)
def start(self) -> None:
if self._mgr is None or self._stream is not None:
return
try:
self._stream = self._mgr.acquire(
self._device_index,
self._loopback,
engine_type=self._engine_type,
engine_config=self._engine_config,
)
except Exception as e: # acquisition is best-effort — never break the effect
logger.warning("AudioEnergyTap: failed to acquire audio capture: %s", e)
self._stream = None
def stop(self) -> None:
if self._mgr is not None and self._stream is not None:
try:
self._mgr.release(self._device_index, self._loopback, engine_type=self._engine_type)
except Exception: # release is best-effort
pass
self._stream = None
self._smoothed = 0.0
def energy(self, smoothing: float = 0.4) -> float:
"""Return smoothed loudness in [0, 1] (0 when no capture/analysis yet).
``smoothing`` is the inertia of the EMA: 0 = instantaneous, →1 = sluggish.
"""
if self._stream is None:
return 0.0
analysis = self._stream.get_latest_analysis()
raw = 0.0
if analysis is not None:
raw = min(1.0, max(0.0, float(getattr(analysis, "rms", 0.0)) * _RMS_GAIN))
a = max(0.0, min(0.99, smoothing))
self._smoothed = self._smoothed * a + raw * (1.0 - a)
return self._smoothed
@@ -246,6 +246,13 @@ class ColorStripStreamManager:
# Inject gradient store for palette resolution
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
css_stream.set_gradient_store(self._gradient_store)
# Inject audio capture deps for audio-reactive effects
if self._audio_capture_manager and hasattr(css_stream, "set_audio_capture"):
css_stream.set_audio_capture(
self._audio_capture_manager,
self._audio_source_store,
self._audio_template_store,
)
# Inject asset store for notification sound playback
if self._asset_store and hasattr(css_stream, "set_asset_store"):
css_stream.set_asset_store(self._asset_store)
@@ -10,7 +10,6 @@ to the user's location and the current season.
"""
import datetime
import math
import threading
import time
@@ -84,71 +83,16 @@ _daylight_lut: np.ndarray | None = None
# ── Solar position helpers ──────────────────────────────────────────────
def _compute_solar_times(
latitude: float,
longitude: float,
day_of_year: int,
utc_offset_hours: float = 0.0,
) -> tuple:
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time.
Uses simplified NOAA solar equations:
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
- hour angle: cos(ha) = -tan(lat) * tan(decl)
- solar noon (UTC): 12 - longitude/15
- wall-clock sunrise/sunset: solar_noon_utc + utc_offset ∓ ha/15
Polar day and polar night are clamped to visible ranges.
"""
deg2rad = math.pi / 180.0
decl_deg = 23.45 * math.sin(2.0 * math.pi * (284 + day_of_year) / 365.0)
decl_rad = decl_deg * deg2rad
lat_rad = latitude * deg2rad
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
solar_noon_utc = 12.0 - longitude / 15.0
solar_noon_local = solar_noon_utc + utc_offset_hours
if cos_ha <= -1.0:
# Polar day — sun never sets; fake a long visible window
sunrise = solar_noon_local - 9.0
sunset = solar_noon_local + 9.0
elif cos_ha >= 1.0:
# Polar night — sun never rises; collapse to noon
sunrise = solar_noon_local
sunset = solar_noon_local
else:
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
sunrise = solar_noon_local - ha_hours
sunset = solar_noon_local + ha_hours
# Clamp to a safe range the LUT builder can render. With reasonable
# tz/longitude pairs sunrise lands in (3..10) and sunset in (14..21);
# we widen the clamp so weird tz/lon combinations still produce a
# usable curve instead of dividing by zero.
sunrise = max(0.5, min(11.5, sunrise))
sunset = max(12.5, min(23.5, sunset))
return sunrise, sunset
def _utc_offset_hours_for(tz_name: str, when: datetime.datetime | None = None) -> float:
"""Return the UTC offset (in hours) for the given IANA timezone.
Empty/unknown tz falls back to the system local offset for ``when``.
"""
when = when or datetime.datetime.now()
if tz_name and ZoneInfo is not None:
try:
offset = when.replace(tzinfo=None).astimezone(ZoneInfo(tz_name)).utcoffset()
if offset is not None:
return offset.total_seconds() / 3600.0
except ZoneInfoNotFoundError:
pass
local_offset = when.astimezone().utcoffset()
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
#
# ``compute_solar_times`` / ``utc_offset_hours_for`` moved to
# ``ledgrab.utils.solar`` (pure math, no project imports) so the automation
# engine can reuse them without importing this processing module. Imported
# under their old private names here so the rest of this file (and
# ``value_stream``, which imports them from here) is unchanged.
from ledgrab.utils.solar import ( # noqa: E402
compute_solar_times as _compute_solar_times,
utc_offset_hours_for as _utc_offset_hours_for,
)
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
@@ -130,35 +130,41 @@ class DeviceHealthMixin:
"latency_ms": state.health.latency_ms,
}
)
# Audit record for device online/offline transition.
from ledgrab.core.activity_log.recorder import get_module_recorder
# Audit record for device online/offline transition — best-effort.
# Wrapped so an instrumentation/import regression can never escape
# into _health_check_loop, whose top-level handler exits the loop
# permanently with no re-spawn (mirrors discovery_watcher._emit).
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
is_online = state.health.online
# Best-effort name lookup from the device store.
device_name: str | None = None
try:
if self._device_store is not None:
device_name = self._device_store.get_device(device_id).name
except Exception:
pass
safe_name = sanitize_display(device_name) if device_name else None
display = safe_name or device_id
action = "device.online" if is_online else "device.offline"
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
status_word = "came online" if is_online else "went offline"
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=device_id,
entity_name=safe_name,
message=f"Device '{display}' {status_word}",
metadata={"latency_ms": state.health.latency_ms},
)
rec = get_module_recorder()
if rec is not None:
is_online = state.health.online
# Best-effort name lookup from the device store.
device_name: str | None = None
try:
if self._device_store is not None:
device_name = self._device_store.get_device(device_id).name
except Exception:
pass
safe_name = sanitize_display(device_name) if device_name else None
display = safe_name or device_id
action = "device.online" if is_online else "device.offline"
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
status_word = "came online" if is_online else "went offline"
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=device_id,
entity_name=safe_name,
message=f"Device '{display}' {status_word}",
metadata={"latency_ms": state.health.latency_ms},
)
except Exception as e:
logger.debug("Device health: audit record failed for %s: %s", device_id, e)
# Auto-sync LED count
reported = state.health.device_led_count
@@ -304,6 +304,13 @@ class EffectColorStripStream(ColorStripStream):
# Sparkle rain state
self._sparkle_state: np.ndarray | None = None # per-LED brightness 0..1
self._gradient_store = None # injected by stream manager
# Audio-reactive modulation (tap injected by stream manager via
# set_audio_capture; defaults keep the effect working with no audio).
self._audio_tap = None
self._audio_reactive = False
self._reactive_mode = "brightness"
self._reactive_audio_source_id = ""
self._reactive_intensity = 0.7
self._update_from_source(source)
def set_gradient_store(self, gradient_store) -> None:
@@ -344,9 +351,36 @@ class EffectColorStripStream(ColorStripStream):
self._intensity = bfloat(getattr(source, "intensity", 1.0), 1.0)
self._scale = bfloat(getattr(source, "scale", 1.0), 1.0)
self._mirror = bool(getattr(source, "mirror", False))
# Audio-reactive params
self._audio_reactive = bool(getattr(source, "audio_reactive", False))
self._reactive_mode = getattr(source, "reactive_mode", "brightness") or "brightness"
self._reactive_intensity = bfloat(getattr(source, "reactive_intensity", 0.7), 0.7)
self._reactive_audio_source_id = getattr(source, "reactive_audio_source_id", "") or ""
if self._audio_tap is not None:
self._audio_tap.configure(self._reactive_audio_source_id)
with self._colors_lock:
self._colors: np.ndarray | None = None
def set_audio_capture(
self, audio_capture_manager, audio_source_store=None, audio_template_store=None
) -> None:
"""Inject audio capture deps so the effect can react to loudness.
Called by the stream manager after construction (mirrors
``set_gradient_store``). Builds the tap and resolves the referenced
audio source; a missing manager just leaves the effect non-reactive.
"""
if audio_capture_manager is None:
return
from ledgrab.core.processing.audio_energy_tap import AudioEnergyTap
self._audio_tap = AudioEnergyTap(
audio_capture_manager, audio_source_store, audio_template_store
)
self._audio_tap.configure(self._reactive_audio_source_id)
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
@@ -369,6 +403,8 @@ class EffectColorStripStream(ColorStripStream):
def start(self) -> None:
if self._running:
return
if self._audio_reactive and self._audio_tap is not None:
self._audio_tap.start()
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
@@ -387,6 +423,8 @@ class EffectColorStripStream(ColorStripStream):
if self._thread.is_alive():
logger.warning("EffectColorStripStream animate thread did not terminate within 5s")
self._thread = None
if self._audio_tap is not None:
self._audio_tap.stop()
self._heat = None
self._heat_n = 0
logger.info("EffectColorStripStream stopped")
@@ -399,12 +437,39 @@ class EffectColorStripStream(ColorStripStream):
from ledgrab.storage.color_strip_source import EffectColorStripSource
if isinstance(source, EffectColorStripSource):
# Release the audio capture before reconfiguring so a changed
# audio source / loopback is re-acquired cleanly.
tap_was_active = self._audio_tap is not None and self._audio_tap.active
if tap_was_active:
self._audio_tap.stop()
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
if self._running and self._audio_reactive and self._audio_tap is not None:
self._audio_tap.start()
logger.info("EffectColorStripStream params updated in-place")
def _apply_audio_modulation(self, buf: np.ndarray) -> None:
"""Scale brightness and/or saturation of the rendered frame by loudness.
Quiet audio dims/desaturates toward ``1 - intensity``; loud audio drives
full brightness (and a saturation boost up to ``1 + intensity``).
"""
e = self._audio_tap.energy()
k = max(0.0, min(1.0, self.resolve("reactive_intensity", self._reactive_intensity)))
if k <= 0.0:
return
f = buf.astype(np.float32)
if self._reactive_mode in ("brightness", "both"):
f *= (1.0 - k) + k * e
if self._reactive_mode in ("saturation", "both"):
sat = (1.0 - k) + 2.0 * k * e
lum = (f[:, 0] * 0.299 + f[:, 1] * 0.587 + f[:, 2] * 0.114)[:, None]
f = lum + (f - lum) * sat
np.clip(f, 0.0, 255.0, out=f)
buf[:] = f.astype(np.uint8)
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock
@@ -476,6 +541,9 @@ class EffectColorStripStream(ColorStripStream):
continue
render_fn(self, buf, n, anim_time)
if self._audio_reactive and self._audio_tap is not None:
self._apply_audio_modulation(buf)
with self._colors_lock:
self._colors = buf
except Exception as e:
@@ -252,18 +252,24 @@ class PlaylistEngine:
order = self._resolve_order(playlist)
applied_any = False
for index, item in enumerate(order):
for orig_index, item in order:
duration = clamp_duration(item.duration_seconds)
if self._state is not None:
self._state.current_index = index
self._state.current_preset_id = item.scene_preset_id
self._state.step_started_at = datetime.now(timezone.utc)
self._state.step_duration = duration
applied = await self._apply_item(item.scene_preset_id)
if applied:
# Only advertise runtime state for a step we actually applied
# and are about to dwell on — a missing/skipped preset must not
# briefly show up in get_state(). ``current_index`` is the
# ORIGINAL persisted items[] index (not the shuffled position)
# so clients can correlate it back to the items array.
if self._state is not None:
self._state.current_index = orig_index
self._state.current_preset_id = item.scene_preset_id
self._state.step_started_at = datetime.now(timezone.utc)
self._state.step_duration = duration
applied_any = True
self._fire_event("advanced", index=index, preset_id=item.scene_preset_id)
self._fire_event("advanced", index=orig_index, preset_id=item.scene_preset_id)
# Only dwell on scenes we actually applied; skip missing ones
# immediately so the cycle doesn't stall on a dead reference.
await asyncio.sleep(duration)
@@ -271,11 +277,16 @@ class PlaylistEngine:
return applied_any
def _resolve_order(self, playlist: ScenePlaylist) -> List:
if playlist.shuffle and len(playlist.items) > 1:
shuffled = list(playlist.items)
random.shuffle(shuffled) # noqa: S311 - cosmetic ordering, not security
return shuffled
return list(playlist.items)
"""Return ``(orig_index, item)`` pairs, optionally shuffled.
The original persisted index travels with each item so ``current_index``
in the runtime state always maps back into ``playlist.items`` even when
shuffle reorders the cycle.
"""
indexed = list(enumerate(playlist.items))
if playlist.shuffle and len(indexed) > 1:
random.shuffle(indexed) # noqa: S311 - cosmetic ordering, not security
return indexed
async def _apply_item(self, preset_id: str) -> bool:
"""Apply one scene preset. Returns False if it could not be applied."""
+17
View File
@@ -51,6 +51,7 @@ from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
from ledgrab.core.game_integration.community_loader import register_community_adapters
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
@@ -185,6 +186,8 @@ audio_processing_template_store = AudioProcessingTemplateStore(db)
game_integration_store = GameIntegrationStore(db)
pattern_template_store = PatternTemplateStore(db)
game_event_bus = GameEventBus()
# Owns LoL Live-Client-Data poll threads (League is poll-only, not GSI-push).
lol_poll_manager = LoLPollManager(game_event_bus)
register_community_adapters()
# Activity log repository — constructed at module level like other stores so
@@ -369,6 +372,7 @@ async def lifespan(app: FastAPI):
ha_manager=ha_manager,
game_integration_store=game_integration_store,
game_event_bus=game_event_bus,
lol_poll_manager=lol_poll_manager,
mqtt_store=mqtt_source_store,
mqtt_manager=mqtt_manager,
http_endpoint_store=http_endpoint_store,
@@ -429,6 +433,13 @@ async def lifespan(app: FastAPI):
# Start update checker (periodic release polling)
await update_service.start()
# Start LoL Live-Client-Data pollers for any enabled League integrations
# (League is poll-only; GSI games push into the ingest endpoint instead).
try:
lol_poll_manager.sync(game_integration_store.get_all_integrations())
except Exception as e:
logger.error(f"Failed to start LoL pollers: {e}")
# Start OS notification listener (Windows toast → notification CSS streams)
os_notif_listener = OsNotificationListener(
color_strip_store=color_strip_store,
@@ -502,6 +513,12 @@ async def lifespan(app: FastAPI):
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
# Stop LoL poller threads so they stop publishing game events.
try:
lol_poll_manager.stop_all()
except Exception as e:
logger.error(f"Error stopping LoL pollers: {e}")
# Tear down any active calibration session BEFORE stop_all so the device
# isn't left stuck in the white-chase and its prior target is restored.
# stop() is a no-op when no session is active.
@@ -196,6 +196,37 @@
width: 100%;
}
/* ── Solar (sunrise/sunset) rule ───────────────────────────── */
.solar-event-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.solar-event-row select {
flex: 1 1 8rem;
min-width: 8rem;
}
.solar-event-row input[type="number"] {
width: 5rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.solar-offset-unit {
font-size: 0.8rem;
color: var(--text-muted);
}
.solar-coord-row {
display: flex;
gap: 10px;
}
.solar-coord-row .rule-field {
flex: 1;
}
.solar-coord-row input[type="number"] {
width: 100%;
}
.time-range-label {
font-size: 0.65rem;
font-weight: 700;
+41
View File
@@ -3261,6 +3261,46 @@
user-select: none;
}
/* Color-harmony generator (gradient editor) */
.gradient-harmony-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.gradient-harmony-row input[type="color"] {
width: 38px;
height: 30px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 6px;
background: none;
cursor: pointer;
flex-shrink: 0;
}
.gradient-harmony-types {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.gradient-harmony-btn {
padding: 4px 10px;
font-size: 0.75rem;
}
/* Linear-light toggle row (calibration editor) */
.calibration-linear-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.calibration-linear-row .input-hint {
flex-basis: 100%;
margin: 0;
}
#gradient-canvas,
#ge-gradient-canvas {
width: 100%;
@@ -4755,6 +4795,7 @@ body.composite-layer-dragging .composite-layer-drag-handle {
.ds-section[data-ds-key="filters"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="routing"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="output"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="power"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="filtering"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="broker"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="protocol"] { animation-delay: 0.10s; }
+4 -2
View File
@@ -107,7 +107,7 @@ import {
import {
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationRule,
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
toggleAutomationEnabled, triggerAutomationNow, cloneAutomation, deleteAutomation, copyWebhookUrl,
} from './features/automations.ts';
import {
showGameIntegrationEditor, saveGameIntegration, closeGameIntegrationModal,
@@ -150,7 +150,7 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
onCSSTypeChange, onEffectTypeChange, onEffectReactiveToggle, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
onAudioVizChange,
@@ -505,6 +505,7 @@ Object.assign(window, {
saveAutomationEditor,
addAutomationRule,
toggleAutomationEnabled,
triggerAutomationNow,
cloneAutomation,
deleteAutomation,
copyWebhookUrl,
@@ -586,6 +587,7 @@ Object.assign(window, {
deleteColorStrip,
onCSSTypeChange,
onEffectTypeChange,
onEffectReactiveToggle,
onEffectPaletteChange,
onCSSClockChange,
onAnimationTypeChange,
+15 -2
View File
@@ -7,6 +7,9 @@ import { IconSelect } from './icon-select.ts';
let currentLocale = 'en';
let _localeIconSelect: IconSelect | null = null;
let translations = {};
// English baseline kept in memory so a key missing from the active locale
// degrades to readable English rather than a raw dotted identifier.
let baseTranslations = {};
let _initialized = false;
const supportedLocales = {
@@ -37,9 +40,12 @@ export function t(key: string, params: Record<string, any> = {}) {
let text;
if ('count' in params) {
const form = getPluralForm(currentLocale, params.count);
text = translations[`${key}.${form}`] || translations[key] || fallbackTranslations[key] || key;
const enForm = getPluralForm('en', params.count);
text = translations[`${key}.${form}`] || translations[key]
|| baseTranslations[`${key}.${enForm}`] || baseTranslations[`${key}.${form}`]
|| baseTranslations[key] || fallbackTranslations[key] || key;
} else {
text = translations[key] || fallbackTranslations[key] || key;
text = translations[key] || baseTranslations[key] || fallbackTranslations[key] || key;
}
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
@@ -80,6 +86,13 @@ export async function setLocale(locale: string) {
}
translations = await loadTranslations(locale);
// Keep an English baseline so any key missing from a non-English locale
// resolves to English instead of a raw dotted key (see t()).
if (locale === 'en') {
baseTranslations = translations;
} else if (Object.keys(baseTranslations).length === 0) {
baseTranslations = await loadTranslations('en');
}
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
@@ -81,6 +81,11 @@ let _liveEventListener: ((e: Event) => void) | null = null;
let _loadingDelayTimer: ReturnType<typeof setTimeout> | null = null;
let _showSpinner = false;
let _hasLoadedOnce = false;
let _languageListenerAttached = false;
// Upper bound on live-prepended rows (array + DOM) so a long-lived session on a
// busy install doesn't grow without limit. Well above the load-more window.
const MAX_LIVE_ROWS = 200;
const _filters: ActiveFilters = {
categories: [],
@@ -178,14 +183,26 @@ export function localizeMessage(entry: ActivityEntry): string {
// ─── Build query string from active filters + cursor ────────
/**
* Convert a `datetime-local` value (`YYYY-MM-DDTHH:MM`, interpreted as the
* user's LOCAL wall-clock by both the picker and `new Date()`) into a real UTC
* ISO-8601 instant for the server. Stored `ts` values are UTC, so sending the
* naive local string would mis-filter by the user's UTC offset. Returns the
* input unchanged if it does not parse.
*/
function _localToUtcIso(localDatetime: string): string {
const d = new Date(localDatetime);
return isNaN(d.getTime()) ? localDatetime : d.toISOString();
}
function _buildQuery(beforeSeq: number | null = null): string {
const params = new URLSearchParams();
for (const cat of _filters.categories) params.append('categories', cat);
for (const sev of _filters.severities) params.append('severities', sev);
if (_filters.actor) params.set('actor', _filters.actor);
if (_filters.entity_type) params.set('entity_type', _filters.entity_type);
if (_filters.since) params.set('since', _filters.since);
if (_filters.until) params.set('until', _filters.until);
if (_filters.since) params.set('since', _localToUtcIso(_filters.since));
if (_filters.until) params.set('until', _localToUtcIso(_filters.until));
if (_filters.q) params.set('q', _filters.q);
if (beforeSeq != null) params.set('before_seq', String(beforeSeq));
params.set('limit', '50');
@@ -392,7 +409,7 @@ function _renderList(): string {
<span>${escapeHtml(t('activity_log.live'))}</span>
</div>
</div>
<div class="al-list" role="log" aria-label="${escapeHtml(t('activity_log.title'))}" aria-live="polite">
<div class="al-list" role="log" aria-label="${escapeHtml(t('activity_log.title'))}">
${rows}
</div>
${loadMore}`;
@@ -475,6 +492,17 @@ function _attachDelegatedClicks(): void {
if (entryId) activityLogToggleDetail(entryId);
}
});
// Dismiss the export dropdown on any click outside the export wrap (the
// panel-scoped handler above only fires for clicks inside the panel).
// Mirrors the document-level outside-click pattern used by mod-menu and the
// other dropdowns. The early-return for clicks inside .al-export-wrap avoids
// double-toggling with the in-panel toggle handler.
document.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('.al-export-wrap')) return;
_closeExportMenu();
});
}
// ─── Full panel render ───────────────────────────────────────
@@ -636,11 +664,24 @@ function _entryPassesFilters(entry: ActivityEntry): boolean {
function _prependLiveEntry(entry: ActivityEntry): void {
if (!_entryPassesFilters(entry)) return;
// Only prepend while the tab is actually visible. The global
// `server:activity_logged` event fires regardless of the active tab, and
// the panel persists when hidden (tab switch only toggles a CSS class), so
// without this guard a hidden tab would accumulate unbounded `_entries` and
// DOM rows on a busy install. The loader re-fetches a fresh page on re-entry.
const panel = document.getElementById('tab-activity_log');
if (!panel || !panel.classList.contains('active') || document.hidden) return;
_entries = [entry, ..._entries];
_total = _total + 1;
// Bound in-memory growth: keep the array within a generous cap above the
// load-more window so a long-lived session can't grow without limit.
if (_entries.length > MAX_LIVE_ROWS) {
_entries = _entries.slice(0, MAX_LIVE_ROWS);
}
// Prepend the row into the existing list (no full re-render for performance)
const list = document.getElementById('tab-activity_log')?.querySelector('.al-list');
const list = panel.querySelector('.al-list');
if (list) {
const html = _renderEntryRow(entry, true);
list.insertAdjacentHTML('afterbegin', html);
@@ -649,6 +690,9 @@ function _prependLiveEntry(entry: ActivityEntry): void {
if (firstRow) {
requestAnimationFrame(() => { firstRow.classList.add('al-entry-appear'); });
}
// Bound DOM growth: drop trailing rows beyond the cap.
const rowEls = list.querySelectorAll('.al-entry');
for (let i = MAX_LIVE_ROWS; i < rowEls.length; i++) rowEls[i].remove();
// Update count badge
const countEl = list.closest('.al-panel')?.querySelector('.al-count');
if (countEl) countEl.textContent = t('activity_log.n_entries', { n: _total });
@@ -683,7 +727,16 @@ export function activityLogToggleDetail(entryId: string): void {
if (!row) return;
const entry = _entries.find(e => e.id === entryId);
if (!entry) return;
// Was the keyboard focus inside the row we're about to replace? outerHTML
// destroys the focused .al-entry-row node, so focus would fall to <body>.
const wasFocused = row.contains(document.activeElement);
row.outerHTML = _renderEntryRow(entry, false);
if (wasFocused) {
// Restore focus to the recreated row so keyboard users keep their place.
const newRow = panel.querySelector<HTMLElement>(
`[data-al-id="${CSS.escape(entryId)}"] .al-entry-row[data-toggle-id]`);
newRow?.focus();
}
}
export function activityLogToggleCat(cat: string): void {
@@ -756,10 +809,13 @@ export function activityLogPreset(key: string): void {
switch (key) {
case 'today': {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
// datetime-local format: YYYY-MM-DDTHH:MM
_filters.since = todayStart.toISOString().slice(0, 16);
const d = new Date();
d.setHours(0, 0, 0, 0);
// Build a LOCAL-wall-clock datetime-local string (YYYY-MM-DDTHH:MM)
// so it matches the manual since/until pickers and renders correctly
// in the input. _buildQuery converts it to a UTC instant for the API.
const pad = (n: number) => String(n).padStart(2, '0');
_filters.since = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00`;
break;
}
case 'errors':
@@ -863,8 +919,13 @@ export async function loadActivityLog(): Promise<void> {
_startLiveUpdates();
ensureRelativeTimeTicker();
// Re-render on language change (baked-in t() calls)
document.addEventListener('languageChanged', _onLanguageChanged);
// Re-render on language change (baked-in t() calls). Guard so re-entering
// the tab (loadActivityLog runs on every tab activation) doesn't stack
// duplicate listeners.
if (!_languageListenerAttached) {
document.addEventListener('languageChanged', _onLanguageChanged);
_languageListenerAttached = true;
}
}
function _onLanguageChanged(): void {
@@ -490,13 +490,17 @@ export async function autoCalSweepForward(): Promise<void> {
_state.busy = true;
try {
await _setPosition(next);
// The user may have cancelled (unmount nulls _state) during the await.
if (!_state) return;
_state.currentIndex = next;
_state.errorMsg = '';
} catch (err: unknown) {
_state.errorMsg = _errMsg(err);
if (_state) _state.errorMsg = _errMsg(err);
} finally {
_state.busy = false;
_render();
if (_state) {
_state.busy = false;
_render();
}
}
}
@@ -511,13 +515,16 @@ export async function autoCalSweepBack(): Promise<void> {
_state.busy = true;
try {
await _setPosition(prev);
if (!_state) return;
_state.currentIndex = prev;
_state.errorMsg = '';
} catch (err: unknown) {
_state.errorMsg = _errMsg(err);
if (_state) _state.errorMsg = _errMsg(err);
} finally {
_state.busy = false;
_render();
if (_state) {
_state.busy = false;
_render();
}
}
}
@@ -530,12 +537,12 @@ export async function autoCalMarkCorner(): Promise<void> {
_state.busy = true;
try {
await _setPosition(next);
_state.currentIndex = next;
if (_state) _state.currentIndex = next;
} catch { /* best effort */ } finally {
_state.busy = false;
if (_state) _state.busy = false;
}
}
_render();
if (_state) _render();
}
export async function autoCalBackToDirection(): Promise<void> {
@@ -564,11 +571,14 @@ export async function autoCalSolve(): Promise<void> {
offset: 0,
}, { errorMessage: t('autocal.error.solve_failed') });
if (!_state) return;
_state.solved = solved;
// Stop the chase session — device restored to prior target
await _stopSession();
if (!_state) return;
_state.step = 'preview';
} catch (err: unknown) {
if (!_state) return;
_state.errorMsg = _errMsg(err);
_state.busy = false;
_render();
@@ -683,6 +693,8 @@ export async function autoCalSave(): Promise<void> {
if (onComplete) onComplete();
} catch (err: unknown) {
// The user may have cancelled (unmount nulls _state) during the await.
if (!_state) return;
_state.busy = false;
_state.errorMsg = _errMsg(err);
if (btn) btn.removeAttribute('disabled');
@@ -344,6 +344,7 @@ type RuleChipBuilder = (c: any) => ModChipOpts;
the scene activation. */
const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }),
manual_trigger: () => ({ icon: _icon(P.zap), text: t('automations.rule.manual_trigger') }),
application: (c) => {
const apps = (c.apps || []).join(', ') || '—';
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
@@ -358,6 +359,22 @@ const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
if (c.timezone) text += ` · ${c.timezone}`;
return { icon: ICON_CLOCK, text, title: t('automations.rule.time_of_day') };
},
solar: (c) => {
const days: number[] = Array.isArray(c.days_of_week) ? c.days_of_week : [];
const evt = (event: any, offset: any, dflt: string): string => {
const e = event === 'sunrise' || event === 'sunset' ? event : dflt;
const label = t('automations.rule.solar.event.' + e);
const off = Number(offset) || 0;
if (!off) return label;
return `${label} ${off > 0 ? '+' : ''}${Math.abs(off)}m`;
};
let text = `${evt(c.start_event, c.start_offset_minutes, 'sunset')} ${evt(c.end_event, c.end_offset_minutes, 'sunrise')}`;
if (days.length && days.length < 7) {
text += ` · ${[...days].sort((a, b) => a - b).map((d) => t('weekday.short.' + d)).join(' ')}`;
}
if (c.timezone) text += ` · ${c.timezone}`;
return { icon: _icon(P.sun), text, title: t('automations.rule.solar') };
},
system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
@@ -544,6 +561,8 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
// "AUTO · 07" pattern (last 2 hex chars, uppercase). ──
const shortId = (automation.id || '').replace(/^auto_/i, '').slice(-2).toUpperCase() || 'NA';
const hasManual = (automation.rules || []).some((r: any) => r.rule_type === 'manual_trigger');
const mod: ModCardOpts = {
running: automation.is_active,
head: {
@@ -564,6 +583,15 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
foot: {
patchState,
patchLabel,
primaryAction: hasManual
? {
label: t('automations.action.trigger'),
icon: ICON_START,
onclick: `triggerAutomationNow('${automation.id}')`,
title: t('automations.trigger.tooltip'),
variant: 'go',
}
: undefined,
secondaryActions: [
automation.enabled
? { label: t('automations.action.disable'), icon: ICON_PAUSE, onclick: `toggleAutomationEnabled('${automation.id}', false)`, variant: 'stop' }
@@ -784,10 +812,10 @@ export function addAutomationRule() {
_autoGenerateAutomationName();
}
const RULE_TYPE_KEYS: RuleType[] = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant', 'http_poll'];
const RULE_TYPE_KEYS: RuleType[] = ['startup', 'manual_trigger', 'application', 'time_of_day', 'solar', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant', 'http_poll'];
const RULE_TYPE_ICONS = {
startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
startup: P.power, manual_trigger: P.zap, application: P.smartphone,
time_of_day: P.clock, solar: P.sun, system_idle: P.moon, display_state: P.monitor,
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
http_poll: P.globe,
};
@@ -885,6 +913,10 @@ function _renderStartupFields(container: HTMLElement, _data: any): void {
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
}
function _renderManualTriggerFields(container: HTMLElement, _data: any): void {
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.manual_trigger.hint')}</small>`;
}
function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
const startTime = data.start_time || '00:00';
const endTime = data.end_time || '23:59';
@@ -936,6 +968,68 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
});
}
function _renderSolarFields(container: HTMLElement, data: any): void {
const startEvent = data.start_event === 'sunrise' ? 'sunrise' : 'sunset';
const endEvent = data.end_event === 'sunset' ? 'sunset' : 'sunrise';
const startOff = Number.isFinite(data.start_offset_minutes) ? data.start_offset_minutes : 0;
const endOff = Number.isFinite(data.end_offset_minutes) ? data.end_offset_minutes : 0;
const lat = Number.isFinite(data.latitude) ? data.latitude : 50;
const lon = Number.isFinite(data.longitude) ? data.longitude : 0;
const days: number[] = Array.isArray(data.days_of_week) ? data.days_of_week : [];
const tz: string = data.timezone || '';
const dayChips = [0, 1, 2, 3, 4, 5, 6]
.map((d) => `<button type="button" class="weekday-chip${days.includes(d) ? ' active' : ''}" data-day="${d}">${t('weekday.short.' + d)}</button>`)
.join('');
const eventOpts = (sel: string) => `
<option value="sunrise" ${sel === 'sunrise' ? 'selected' : ''}>${t('automations.rule.solar.event.sunrise')}</option>
<option value="sunset" ${sel === 'sunset' ? 'selected' : ''}>${t('automations.rule.solar.event.sunset')}</option>`;
container.innerHTML = `
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.solar.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.solar.start')}</label>
<div class="solar-event-row">
<select class="rule-solar-start-event">${eventOpts(startEvent)}</select>
<input type="number" class="rule-solar-start-offset" min="-1439" max="1439" step="5" value="${startOff}" title="${escapeHtml(t('automations.rule.solar.offset'))}">
<span class="solar-offset-unit">${t('automations.rule.solar.offset.unit')}</span>
</div>
</div>
<div class="rule-field">
<label>${t('automations.rule.solar.end')}</label>
<div class="solar-event-row">
<select class="rule-solar-end-event">${eventOpts(endEvent)}</select>
<input type="number" class="rule-solar-end-offset" min="-1439" max="1439" step="5" value="${endOff}" title="${escapeHtml(t('automations.rule.solar.offset'))}">
<span class="solar-offset-unit">${t('automations.rule.solar.offset.unit')}</span>
</div>
</div>
<div class="solar-coord-row">
<div class="rule-field">
<label>${t('automations.rule.solar.latitude')}</label>
<input type="number" class="rule-solar-latitude" min="-90" max="90" step="0.0001" value="${lat}">
</div>
<div class="rule-field">
<label>${t('automations.rule.solar.longitude')}</label>
<input type="number" class="rule-solar-longitude" min="-180" max="180" step="0.0001" value="${lon}">
</div>
</div>
<small class="rule-hint-desc">${t('automations.rule.solar.location_hint')}</small>
<div class="rule-weekday-block">
<span class="rule-field-label">${t('automations.rule.time_of_day.days')}</span>
<div class="weekday-chips">${dayChips}</div>
<small class="rule-hint-desc">${t('automations.rule.time_of_day.days_hint')}</small>
</div>
<div class="rule-tz-block">
<label class="rule-field-label">${t('automations.rule.time_of_day.timezone')}</label>
<input type="text" class="rule-timezone" placeholder="${escapeHtml(t('automations.rule.time_of_day.timezone.placeholder'))}" value="${escapeHtml(tz)}">
</div>
</div>`;
enhanceMiniSelects(container, 'select.rule-solar-start-event');
enhanceMiniSelects(container, 'select.rule-solar-end-event');
container.querySelectorAll('.weekday-chip').forEach((chip) => {
chip.addEventListener('click', () => chip.classList.toggle('active'));
});
}
function _renderSystemIdleFields(container: HTMLElement, data: any): void {
const idleMinutes = data.idle_minutes ?? 5;
const whenIdle = data.when_idle ?? true;
@@ -1254,8 +1348,10 @@ function _renderApplicationFields(container: HTMLElement, data: any): void {
const RULE_FIELD_RENDERERS: Record<RuleType, RuleFieldRenderer> = {
startup: _renderStartupFields,
manual_trigger: _renderManualTriggerFields,
application: _renderApplicationFields,
time_of_day: _renderTimeOfDayFields,
solar: _renderSolarFields,
system_idle: _renderSystemIdleFields,
display_state: _renderDisplayStateFields,
mqtt: _renderMqttFields,
@@ -1340,6 +1436,7 @@ type RuleCollector = (row: Element) => Record<string, any>;
const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
startup: () => ({ rule_type: 'startup' }),
manual_trigger: () => ({ rule_type: 'manual_trigger' }),
time_of_day: (row) => ({
rule_type: 'time_of_day',
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
@@ -1348,6 +1445,22 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
.map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)),
timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(),
}),
solar: (row) => {
const lat = parseFloat((row.querySelector('.rule-solar-latitude') as HTMLInputElement).value);
const lon = parseFloat((row.querySelector('.rule-solar-longitude') as HTMLInputElement).value);
return {
rule_type: 'solar',
start_event: (row.querySelector('.rule-solar-start-event') as HTMLSelectElement).value === 'sunrise' ? 'sunrise' : 'sunset',
start_offset_minutes: parseInt((row.querySelector('.rule-solar-start-offset') as HTMLInputElement).value, 10) || 0,
end_event: (row.querySelector('.rule-solar-end-event') as HTMLSelectElement).value === 'sunset' ? 'sunset' : 'sunrise',
end_offset_minutes: parseInt((row.querySelector('.rule-solar-end-offset') as HTMLInputElement).value, 10) || 0,
latitude: Number.isFinite(lat) ? lat : 50,
longitude: Number.isFinite(lon) ? lon : 0,
days_of_week: Array.from(row.querySelectorAll('.weekday-chip.active'))
.map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)),
timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(),
};
},
system_idle: (row) => ({
rule_type: 'system_idle',
idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5,
@@ -1494,6 +1607,27 @@ export async function toggleAutomationEnabled(automationId: any, enable: any) {
}
}
export async function triggerAutomationNow(automationId: any) {
try {
const r = await apiPost<{ status: string; errors: string[] }>(
`/automations/${automationId}/trigger`, undefined, {
errorMessage: t('automations.error.trigger_failed'),
});
if (r.status === 'skipped') {
showToast(t('automations.trigger.skipped'), 'warning');
} else if (r.errors && r.errors.length) {
showToast(`${t('automations.trigger.partial')} (${r.errors.length})`, 'warning');
} else {
showToast(t('automations.triggered'), 'success');
}
automationsCacheObj.invalidate();
loadAutomations();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t('automations.error.trigger_failed'), 'error');
}
}
export function copyWebhookUrl(btn: any) {
const input = btn.closest('.webhook-url-row').querySelector('.rule-webhook-url') as HTMLInputElement;
if (!input || !input.value) return;
@@ -936,6 +936,10 @@ function _populateRoiInputs(calibration: any): void {
set('cal-roi-y', pct(calibration.roi_y, 0));
set('cal-roi-width', pct(calibration.roi_width, 1));
set('cal-roi-height', pct(calibration.roi_height, 1));
const linEl = document.getElementById('cal-linear-blend') as HTMLInputElement | null;
if (linEl) linEl.checked = !!calibration.linear_blend;
const ditherEl = document.getElementById('cal-dither') as HTMLInputElement | null;
if (ditherEl) ditherEl.checked = !!calibration.dither;
}
export async function saveCalibration() {
@@ -996,6 +1000,8 @@ export async function saveCalibration() {
roi_y: (parseFloat((document.getElementById('cal-roi-y') as HTMLInputElement).value) || 0) / 100,
roi_width: (parseFloat((document.getElementById('cal-roi-width') as HTMLInputElement).value) || 100) / 100,
roi_height: (parseFloat((document.getElementById('cal-roi-height') as HTMLInputElement).value) || 100) / 100,
linear_blend: (document.getElementById('cal-linear-blend') as HTMLInputElement)?.checked || false,
dither: (document.getElementById('cal-dither') as HTMLInputElement)?.checked || false,
};
try {
@@ -13,6 +13,7 @@ import { TagInput, renderTagChips } from '../../core/tag-input.ts';
import { escapeHtml } from '../../core/api.ts';
import {
gradientInit, gradientRenderAll, getGradientStops, gradientSetIdPrefix,
gradientWireHarmony,
} from '../css-gradient-editor.ts';
/* ── Helpers ──────────────────────────────────────────────────── */
@@ -180,6 +181,7 @@ export async function showGradientModal(editId: string | null = null, cloneData:
requestAnimationFrame(() => {
gradientSetIdPrefix('ge-');
gradientInit(stops);
gradientWireHarmony();
gradientEditorModal.snapshot();
});
}
@@ -22,6 +22,9 @@ import type { ColorStripSource } from '../../types.ts';
import { TagInput } from '../../core/tag-input.ts';
import { IconSelect, showTypePicker, type IconSelectItem } from '../../core/icon-select.ts';
import { EntitySelect } from '../../core/entity-palette.ts';
import { enhanceMiniSelects } from '../../core/mini-select.ts';
import { audioSourcesCache } from '../../core/state.ts';
import { getAudioSourceIcon } from '../../core/icons.ts';
import { BindableScalarWidget } from '../../core/bindable-scalar.ts';
import { BindableColorWidget } from '../../core/bindable-color.ts';
import { getBaseOrigin } from '../settings.ts';
@@ -568,6 +571,8 @@ let _animationTypeIconSelect: any = null;
let _interpolationIconSelect: any = null;
let _effectTypeIconSelect: any = null;
let _effectPaletteEntitySelect: EntitySelect | null = null;
let _effectReactiveSourceEntitySelect: EntitySelect | null = null;
let _effectReactiveModeEnhanced = false;
let _gradientPresetEntitySelect: EntitySelect | null = null;
let _gradientEasingIconSelect: any = null;
let _candleTypeIconSelect: any = null;
@@ -637,6 +642,50 @@ function _ensureEffectIntensityWidget(): BindableScalarWidget {
return _effectIntensityWidget;
}
/* ── Effect audio-reactive controls ───────────────────────────── */
/** Show/hide the reactive options group and lazily populate the source list. */
export function onEffectReactiveToggle() {
const on = (document.getElementById('css-editor-effect-audio-reactive') as HTMLInputElement)?.checked;
const group = document.getElementById('css-editor-effect-reactive-group');
if (group) group.style.display = on ? '' : 'none';
if (on) {
void _populateEffectReactiveSource();
const modeSel = document.getElementById('css-editor-effect-reactive-mode');
if (modeSel && !_effectReactiveModeEnhanced) {
enhanceMiniSelects(modeSel.parentElement || document, '#css-editor-effect-reactive-mode');
_effectReactiveModeEnhanced = true;
}
}
}
/** Build the EntitySelect for the reactive audio-source picker from the cache.
* Pass `desired` to select a specific source after (re)building the options. */
async function _populateEffectReactiveSource(desired?: string) {
const select = document.getElementById('css-editor-effect-reactive-source') as HTMLSelectElement | null;
if (!select) return;
try {
const sources: any[] = await audioSourcesCache.fetch();
const target = desired !== undefined ? desired : select.value;
select.innerHTML = sources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
if (target) select.value = target;
if (_effectReactiveSourceEntitySelect) { _effectReactiveSourceEntitySelect.destroy(); _effectReactiveSourceEntitySelect = null; }
if (sources.length > 0) {
_effectReactiveSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => sources.map(s => ({
value: s.id, label: s.name,
icon: getAudioSourceIcon(s.source_type), desc: s.source_type,
})),
placeholder: t('palette.search'),
});
if (target) _effectReactiveSourceEntitySelect.setValue(target);
}
} catch {
select.innerHTML = '';
}
}
function _ensureEffectScaleWidget(): BindableScalarWidget {
if (!_effectScaleWidget) {
_effectScaleWidget = new BindableScalarWidget({
@@ -1041,6 +1090,23 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
_ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
_ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
// Audio reactivity
const reactive = !!css.audio_reactive;
(document.getElementById('css-editor-effect-audio-reactive') as HTMLInputElement).checked = reactive;
(document.getElementById('css-editor-effect-reactive-mode') as HTMLSelectElement).value = css.reactive_mode || 'brightness';
const rIntensity = typeof css.reactive_intensity === 'object' && css.reactive_intensity !== null
? (css.reactive_intensity.value ?? 0.7) : (css.reactive_intensity ?? 0.7);
(document.getElementById('css-editor-effect-reactive-intensity') as HTMLInputElement).value = String(rIntensity);
const rGroup = document.getElementById('css-editor-effect-reactive-group');
if (rGroup) rGroup.style.display = reactive ? '' : 'none';
if (reactive) {
void _populateEffectReactiveSource(css.reactive_audio_source_id || '');
const modeSel = document.getElementById('css-editor-effect-reactive-mode');
if (modeSel && !_effectReactiveModeEnhanced) {
enhanceMiniSelects(modeSel.parentElement || document, '#css-editor-effect-reactive-mode');
_effectReactiveModeEnhanced = true;
}
}
onEffectPaletteChange();
},
reset() {
@@ -1051,6 +1117,11 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
_ensureEffectIntensityWidget().setValue(1.0);
_ensureEffectScaleWidget().setValue(1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
(document.getElementById('css-editor-effect-audio-reactive') as HTMLInputElement).checked = false;
(document.getElementById('css-editor-effect-reactive-mode') as HTMLSelectElement).value = 'brightness';
(document.getElementById('css-editor-effect-reactive-intensity') as HTMLInputElement).value = '0.7';
const rGroup = document.getElementById('css-editor-effect-reactive-group');
if (rGroup) rGroup.style.display = 'none';
},
getPayload(name) {
const payload: any = {
@@ -1060,6 +1131,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
intensity: _ensureEffectIntensityWidget().getValue(),
scale: _ensureEffectScaleWidget().getValue(),
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
audio_reactive: (document.getElementById('css-editor-effect-audio-reactive') as HTMLInputElement).checked,
reactive_audio_source_id: (document.getElementById('css-editor-effect-reactive-source') as HTMLSelectElement).value || '',
reactive_mode: (document.getElementById('css-editor-effect-reactive-mode') as HTMLSelectElement).value || 'brightness',
reactive_intensity: parseFloat((document.getElementById('css-editor-effect-reactive-intensity') as HTMLInputElement).value) || 0.7,
};
if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) {
payload.color = _ensureEffectColorWidget().getValue();
@@ -212,6 +212,112 @@ export function applyGradientPreset(key: string): void {
gradientInit(GRADIENT_PRESETS[key]);
}
/* ── Color harmony generator ──────────────────────────────────── */
export type HarmonyType =
| 'complementary' | 'analogous' | 'triadic'
| 'split_complementary' | 'tetradic' | 'monochromatic';
export const HARMONY_TYPES: HarmonyType[] = [
'complementary', 'analogous', 'triadic',
'split_complementary', 'tetradic', 'monochromatic',
];
/** RGB (0255) → HSV with h in [0,360), s/v in [0,1]. */
function _rgbToHsv(rgb: number[]): [number, number, number] {
const r = rgb[0] / 255, g = rgb[1] / 255, b = rgb[2] / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
const s = max === 0 ? 0 : d / max;
return [h, s, max];
}
/** HSV (h in [0,360), s/v in [0,1]) → RGB (0255). */
function _hsvToRgb(h: number, s: number, v: number): number[] {
h = ((h % 360) + 360) % 360;
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
return [r, g, b].map(ch => Math.round((ch + m) * 255));
}
const _HARMONY_ROTATIONS: Partial<Record<HarmonyType, number[]>> = {
complementary: [0, 180],
analogous: [-30, 0, 30],
triadic: [0, 120, 240],
split_complementary: [0, 150, 210],
tetradic: [0, 90, 180, 270],
};
/**
* Generate evenly-spaced gradient stops from a base color using classic
* color-theory relationships. Monochromatic ramps the value of a single hue;
* every other type rotates the hue by fixed angles and keeps S/V.
*/
export function generateHarmonyStops(baseRgb: number[], type: HarmonyType): GradientPresetStop[] {
const [h, s, v] = _rgbToHsv(baseRgb);
let colors: number[][];
if (type === 'monochromatic') {
const steps = 5;
colors = Array.from({ length: steps }, (_, i) =>
_hsvToRgb(h, Math.min(1, s + 0.05 * (steps - 1 - i)), 0.3 + (0.7 * i) / (steps - 1)),
);
} else {
const rotations = _HARMONY_ROTATIONS[type] || [0, 180];
colors = rotations.map(r => _hsvToRgb(h + r, s, v));
}
const n = colors.length;
return colors.map((color, i) => ({
position: n > 1 ? Math.round((i / (n - 1)) * 100) / 100 : 0,
color,
}));
}
/** Replace the current gradient with a harmony generated from `baseRgb`. */
export function applyColorHarmony(baseRgb: number[], type: HarmonyType): void {
gradientInit(generateHarmonyStops(baseRgb, type));
}
/**
* Wire the harmony controls in the prefixed editor scope (base color input +
* one button per harmony type). Idempotent safe to call on every modal open.
*/
export function gradientWireHarmony(): void {
const container = _el('gradient-harmony-types');
const baseInput = _el('gradient-harmony-base') as HTMLInputElement | null;
if (!container || !baseInput) return;
// Seed the base picker from the current gradient's first stop.
if (_gradientStops.length > 0) {
baseInput.value = rgbArrayToHex(_gradientStops[0].color);
}
if ((container as any)._harmonyBound) return;
(container as any)._harmonyBound = true;
container.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('[data-harmony]') as HTMLElement | null;
if (!btn) return;
const type = btn.dataset.harmony as HarmonyType;
if (!HARMONY_TYPES.includes(type)) return;
applyColorHarmony(hexToRgbArray(baseInput.value), type);
});
}
/* ── Render ───────────────────────────────────────────────────── */
export function gradientRenderAll(): void {
@@ -792,10 +792,22 @@ function _reconcileDynamicSections(dynamic: HTMLElement, newHtml: string): void
// If the new HTML contains non-section top-level nodes (e.g. the
// `.dashboard-no-targets` placeholder shown when there are no entities),
// fall back to a simple innerHTML swap — this path is rare and the
// no-entities state doesn't have live widgets worth preserving.
// fall back to an innerHTML swap. BUT the empty-entities (first-run) state
// DOES include a live widget — the recent-activity section — and the fresh
// HTML always carries its loading-spinner placeholder. A blind swap on every
// 2s poll would wipe the populated list back to the spinner and re-fetch
// /activity-log endlessly. So preserve a live, populated recent-activity
// node by grafting it into the new HTML before swapping.
const totalTopLevel = scratch.children.length;
if (totalTopLevel !== incoming.length) {
const liveRa = dynamic.querySelector<HTMLElement>('#dashboard-recent-activity-list.dal-list');
if (liveRa) {
const scratchRa = scratch.querySelector<HTMLElement>('#dashboard-recent-activity-list');
if (scratchRa) scratchRa.replaceWith(liveRa.cloneNode(true));
const merged = scratch.innerHTML;
if (dynamic.innerHTML !== merged) dynamic.innerHTML = merged;
return;
}
if (dynamic.innerHTML !== newHtml) dynamic.innerHTML = newHtml;
return;
}
@@ -1019,6 +1019,8 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
if (nmi && cloneData.nanoleaf_min_interval_ms != null) {
nmi.value = String(cloneData.nanoleaf_min_interval_ms);
}
const pp = document.getElementById('device-nanoleaf-per-panel') as HTMLInputElement | null;
if (pp) pp.checked = !!cloneData.nanoleaf_per_panel;
}
// Prefill Govee fields
if (isGoveeDevice(presetType)) {
@@ -1254,6 +1256,7 @@ export async function handleAddDevice(event: any) {
const raw = (document.getElementById('device-nanoleaf-min-interval') as HTMLInputElement)?.value;
const parsed = parseInt(raw || '100', 10);
body.nanoleaf_min_interval_ms = Number.isFinite(parsed) ? parsed : 100;
body.nanoleaf_per_panel = (document.getElementById('device-nanoleaf-per-panel') as HTMLInputElement)?.checked || false;
}
if (isBleDevice(deviceType)) {
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
@@ -1654,6 +1657,8 @@ function _showGoveeFields(show: boolean) {
function _showNanoleafFields(show: boolean) {
const el = document.getElementById('device-nanoleaf-min-interval-group') as HTMLElement | null;
if (el) el.style.display = show ? '' : 'none';
const ppEl = document.getElementById('device-nanoleaf-per-panel-group') as HTMLElement | null;
if (ppEl) ppEl.style.display = show ? '' : 'none';
const submitBtn = document.getElementById('add-device-submit-btn') as HTMLButtonElement | null;
if (submitBtn) {
if (show) {
@@ -746,6 +746,10 @@ export async function showSettings(deviceId: any) {
if (nanoleafMinIntervalGroup) (nanoleafMinIntervalGroup as HTMLElement).style.display = '';
const nmi = device.nanoleaf_min_interval_ms ?? 100;
(document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement).value = String(nmi);
const perPanelGroup = document.getElementById('settings-nanoleaf-per-panel-group');
if (perPanelGroup) (perPanelGroup as HTMLElement).style.display = '';
const perPanelEl = document.getElementById('settings-nanoleaf-per-panel') as HTMLInputElement | null;
if (perPanelEl) perPanelEl.checked = !!device.nanoleaf_per_panel;
if (nanoleafPairedGroup) {
(nanoleafPairedGroup as HTMLElement).style.display = device.nanoleaf_paired ? '' : 'none';
}
@@ -933,6 +937,7 @@ export async function saveDeviceSettings() {
const raw = (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement | null)?.value;
const parsed = parseInt(raw || '100', 10);
body.nanoleaf_min_interval_ms = Number.isFinite(parsed) ? parsed : 100;
body.nanoleaf_per_panel = (document.getElementById('settings-nanoleaf-per-panel') as HTMLInputElement | null)?.checked || false;
// Intentionally do NOT include nanoleaf_token here — the token
// is set once at pair time, encrypted at rest, and never
// re-emitted from the settings modal. Re-pairing means
@@ -146,7 +146,7 @@ registerIconEntityType('gradient', makeSimpleIconAdapter<any>({
endpointPrefix: '/gradients',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.gradient',
typeLabelFallback: 'Gradient',
typeLabelFallback: 'Palette',
cardSelectors: (id) => [`[data-card-section="gradients"] [data-id="${CSS.escape(id)}"]`],
}));
@@ -6,9 +6,11 @@
*/
export type RuleType =
| 'application' | 'time_of_day' | 'system_idle'
| 'application' | 'time_of_day' | 'solar' | 'system_idle'
| 'display_state' | 'mqtt' | 'webhook' | 'startup'
| 'home_assistant' | 'http_poll';
| 'home_assistant' | 'http_poll' | 'manual_trigger';
export type SolarEvent = 'sunrise' | 'sunset';
export type HTTPPollOperator =
| 'equals' | 'not_equals' | 'contains' | 'regex'
@@ -20,6 +22,16 @@ export interface AutomationRule {
match_type?: string;
start_time?: string;
end_time?: string;
/** time_of_day + solar rules */
days_of_week?: number[];
timezone?: string;
/** solar rule */
start_event?: SolarEvent;
start_offset_minutes?: number;
end_event?: SolarEvent;
end_offset_minutes?: number;
latitude?: number;
longitude?: number;
idle_minutes?: number;
when_idle?: boolean;
state?: string;
@@ -44,6 +44,7 @@ export interface Device {
govee_min_interval_ms: number;
nanoleaf_paired: boolean;
nanoleaf_min_interval_ms: number;
nanoleaf_per_panel?: boolean;
spi_speed_hz: number;
spi_led_type: string;
chroma_device_type: string;
+70 -24
View File
@@ -75,6 +75,7 @@
"activity_log.msg.audit_log.disabled": "Activity logging disabled",
"activity_log.msg.automation.activated": "Automation '{name}' activated",
"activity_log.msg.automation.deactivated": "Automation '{name}' deactivated",
"activity_log.msg.automation.triggered": "Automation '{name}' manually triggered",
"activity_log.msg.server.shutting_down": "Server shutting down",
"activity_log.msg.server.restarting": "Server restart requested",
"activity_log.msg.server.shutdown_requested": "Server shutdown requested",
@@ -96,7 +97,7 @@
"activity_log.entity_type.scene_playlist": "Scene Playlist",
"activity_log.entity_type.sync_clock": "Sync Clock",
"activity_log.entity_type.template": "Template",
"activity_log.entity_type.gradient": "Gradient",
"activity_log.entity_type.gradient": "Palette",
"activity_log.entity_type.cspt": "Processing Template",
"activity_log.entity_type.audio_template": "Audio Template",
"activity_log.entity_type.audio_processing_template": "Audio Processing Template",
@@ -336,6 +337,7 @@
"automation.disabled": "Automation disabled",
"automation.enabled": "Automation enabled",
"automations.action.disable": "Disable",
"automations.action.trigger": "Trigger",
"automations.add": "Add Automation",
"automations.created": "Automation created",
"automations.deactivation_mode": "Deactivation:",
@@ -360,6 +362,7 @@
"automations.error.name_required": "Name is required",
"automations.error.save_failed": "Failed to save automation",
"automations.error.toggle_failed": "Failed to toggle automation",
"automations.error.trigger_failed": "Failed to trigger automation",
"automations.last_activated": "Last activated",
"automations.logic.all": "ALL",
"automations.logic.and": " AND ",
@@ -427,6 +430,9 @@
"automations.rule.http_poll.value": "Value",
"automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.value_source": "HTTP Value Source",
"automations.rule.manual_trigger": "Manual Trigger",
"automations.rule.manual_trigger.desc": "Run from a button",
"automations.rule.manual_trigger.hint": "Activates only when you press the Trigger button on the automation card (the automation's other rules are still checked).",
"automations.rule.mqtt": "MQTT",
"automations.rule.mqtt.desc": "MQTT message",
"automations.rule.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
@@ -447,6 +453,18 @@
"automations.rule.system_idle.when_active.desc": "Fires while the user is actively using the system",
"automations.rule.system_idle.when_idle": "When idle",
"automations.rule.system_idle.when_idle.desc": "Fires once the user has been idle past the timeout",
"automations.rule.solar": "Sun (Sunrise / Sunset)",
"automations.rule.solar.desc": "Relative to sunrise/sunset",
"automations.rule.solar.hint": "Activate during a window relative to sunrise and sunset at your location. The default — sunset to sunrise — covers \"active at night\".",
"automations.rule.solar.start": "Window opens at",
"automations.rule.solar.end": "Window closes at",
"automations.rule.solar.event.sunrise": "Sunrise",
"automations.rule.solar.event.sunset": "Sunset",
"automations.rule.solar.offset": "Offset in minutes (negative = before the event, positive = after)",
"automations.rule.solar.offset.unit": "min",
"automations.rule.solar.latitude": "Latitude",
"automations.rule.solar.longitude": "Longitude",
"automations.rule.solar.location_hint": "Set your latitude and longitude so sunrise and sunset are computed for your location.",
"automations.rule.time_of_day": "Time of Day",
"automations.rule.time_of_day.days": "Active days",
"automations.rule.time_of_day.days_hint": "Leave all unselected for every day. Overnight windows count toward the day they start on.",
@@ -485,6 +503,10 @@
"automations.status.disabled": "Disabled",
"automations.status.inactive": "Inactive",
"automations.title": "Automations",
"automations.trigger.partial": "Triggered with errors",
"automations.trigger.skipped": "Conditions not met — automation not triggered",
"automations.trigger.tooltip": "Run this automation now (its rules are still checked)",
"automations.triggered": "Automation triggered",
"automations.updated": "Automation updated",
"bg.anim.toggle": "Toggle ambient background",
"bindable.none": "None (static value)",
@@ -524,6 +546,10 @@
"calibration.advanced.switch_to_simple": "Switch to Simple",
"calibration.advanced.title": "Advanced Calibration",
"calibration.border_width": "Border (px):",
"calibration.linear_blend": "Linear-light blending",
"calibration.linear_blend.hint": "Average border pixels in linear light for perceptually correct, brighter colour mixing.",
"calibration.dither": "Dithering",
"calibration.dither.hint": "Spatio-temporal dithering reduces visible banding on smooth gradients.",
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
"calibration.button.cancel": "Cancel",
"calibration.button.save": "Save",
@@ -764,6 +790,14 @@
"color_strip.effect.meteor": "Meteor",
"color_strip.effect.meteor.desc": "Bright head travels along the strip with an exponential-decay tail",
"color_strip.effect.mirror": "Mirror:",
"color_strip.effect.reactive": "Audio reactive:",
"color_strip.effect.reactive.hint": "Modulate this effect's brightness and/or saturation with live audio loudness.",
"color_strip.effect.reactive.source": "Audio source:",
"color_strip.effect.reactive.mode": "Modulate:",
"color_strip.effect.reactive.mode.brightness": "Brightness",
"color_strip.effect.reactive.mode.saturation": "Saturation",
"color_strip.effect.reactive.mode.both": "Both",
"color_strip.effect.reactive.intensity": "Strength:",
"color_strip.effect.mirror.hint": "Bounce mode — the meteor reverses direction at strip ends instead of wrapping.",
"color_strip.effect.noise": "Noise",
"color_strip.effect.noise.desc": "Scrolling fractal value noise mapped to a palette",
@@ -812,8 +846,8 @@
"color_strip.gradient.easing.linear.desc": "Constant-rate blending between stops",
"color_strip.gradient.easing.step": "Step",
"color_strip.gradient.easing.step.desc": "Hard jumps between colors with no blending",
"color_strip.gradient.error.no_gradient": "Please select a gradient",
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops",
"color_strip.gradient.error.no_gradient": "Please select a palette",
"color_strip.gradient.min_stops": "Palette must have at least 2 stops",
"color_strip.gradient.position": "Position (0.01.0)",
"color_strip.gradient.preset": "Preset:",
"color_strip.gradient.preset.apply": "Apply",
@@ -837,8 +871,17 @@
"color_strip.gradient.preset.warm": "Warm",
"color_strip.gradient.preview": "Gradient:",
"color_strip.gradient.preview.hint": "Visual preview. Click the marker track below to add a stop. Drag markers to reposition.",
"color_strip.gradient.select": "Gradient:",
"color_strip.gradient.select.hint": "Select a gradient from the library. Create and edit gradients in the Gradients tab.",
"color_strip.gradient.select": "Palette:",
"color_strip.gradient.select.hint": "Select a palette from the library. Create and edit palettes in the Palettes tab.",
"color_strip.gradient.harmony": "Color harmony:",
"color_strip.gradient.harmony.hint": "Pick a base color, then generate a harmonious set of stops from classic color-theory relationships.",
"color_strip.gradient.harmony.base": "Base color",
"color_strip.gradient.harmony.complementary": "Complementary",
"color_strip.gradient.harmony.analogous": "Analogous",
"color_strip.gradient.harmony.triadic": "Triadic",
"color_strip.gradient.harmony.split_complementary": "Split",
"color_strip.gradient.harmony.tetradic": "Tetradic",
"color_strip.gradient.harmony.monochromatic": "Mono",
"color_strip.gradient.stops": "Color Stops:",
"color_strip.gradient.stops.hint": "Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.",
"color_strip.gradient.stops_count": "stops",
@@ -1371,7 +1414,7 @@
"device.icon.entity.cspt": "Color-strip processing template",
"device.icon.entity.device": "Device",
"device.icon.entity.game_integration": "Game integration",
"device.icon.entity.gradient": "Gradient",
"device.icon.entity.gradient": "Palette",
"device.icon.entity.ha_light_target": "HA light target",
"device.icon.entity.ha_source": "Home Assistant source",
"device.icon.entity.http_endpoint": "HTTP endpoint",
@@ -1498,6 +1541,8 @@
"device.nanoleaf.url.placeholder": "192.168.1.50",
"device.nanoleaf_min_interval": "Min Update Interval:",
"device.nanoleaf_min_interval.hint": "Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.",
"device.nanoleaf_per_panel": "Per-panel streaming:",
"device.nanoleaf_per_panel.hint": "Stream each panel individually via extControl UDP instead of one averaged colour. Requires a recent controller firmware.",
"device.opc.url": "IP Address:",
"device.opc.url.hint": "OPC receiver address. TCP port defaults to 7890.",
"device.opc.url.placeholder": "192.168.1.50",
@@ -1806,26 +1851,26 @@
"game_integration.test.timeout": "No events received within timeout period.",
"game_integration.test.waiting": "Waiting for events from game...",
"game_integration.updated": "Game integration updated",
"gradient.add": "Add Gradient",
"gradient.add": "Add Palette",
"gradient.builtin": "Built-in",
"gradient.cloned": "Gradient cloned",
"gradient.confirm_delete": "Delete gradient \"{name}\"?",
"gradient.create_name": "New gradient name:",
"gradient.created": "Gradient created",
"gradient.deleted": "Gradient deleted",
"gradient.cloned": "Palette cloned",
"gradient.confirm_delete": "Delete palette \"{name}\"?",
"gradient.create_name": "New palette name:",
"gradient.created": "Palette created",
"gradient.deleted": "Palette deleted",
"gradient.description": "Description:",
"gradient.description.hint": "Optional description for this gradient.",
"gradient.edit": "Edit Gradient",
"gradient.edit_name": "Rename gradient:",
"gradient.error.delete_failed": "Failed to delete gradient",
"gradient.description.hint": "Optional description for this palette.",
"gradient.edit": "Edit Palette",
"gradient.edit_name": "Rename palette:",
"gradient.error.delete_failed": "Failed to delete palette",
"gradient.error.min_stops": "At least 2 color stops are required",
"gradient.error.name_required": "Name is required",
"gradient.error.save_failed": "Failed to save gradient",
"gradient.group.title": "Gradients",
"gradient.error.save_failed": "Failed to save palette",
"gradient.group.title": "Palettes",
"gradient.name": "Name:",
"gradient.name.hint": "A descriptive name for this gradient.",
"gradient.name.hint": "A descriptive name for this palette.",
"gradient.stops_label": "stops",
"gradient.updated": "Gradient updated",
"gradient.updated": "Palette updated",
"graph.action.connect": "Connect",
"graph.action.disconnect": "Disconnect",
"graph.action.move": "Move node",
@@ -2323,7 +2368,7 @@
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
"section.empty.devices": "No devices yet. Click + to add one.",
"section.empty.game_integrations": "No game integrations yet. Click + to create one.",
"section.empty.gradients": "No gradients yet",
"section.empty.gradients": "No palettes yet",
"section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
"section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.",
"section.empty.http_endpoints": "No HTTP endpoints yet. Click + to add one.",
@@ -2581,7 +2626,7 @@
"settings.section.file": "File",
"settings.section.filtering": "Filtering",
"settings.section.filters": "Filters",
"settings.section.gradient": "Gradient",
"settings.section.gradient": "Palette",
"settings.section.hardware": "Hardware",
"settings.section.history": "History",
"settings.section.identity": "Identity",
@@ -2597,6 +2642,7 @@
"settings.section.notif_permission": "OS Permission",
"settings.section.offsets": "Offsets",
"settings.section.output": "Output",
"settings.section.power": "Power",
"settings.section.preview": "Preview",
"settings.section.protocol": "Protocol",
"settings.section.provider": "Provider",
@@ -2674,7 +2720,7 @@
"streams.group.color_strip": "Color Strips",
"streams.group.css_processing": "Processing Templates",
"streams.group.game": "Game Integration",
"streams.group.gradients": "Gradients",
"streams.group.gradients": "Palettes",
"streams.group.home_assistant": "Home Assistant",
"streams.group.http": "HTTP",
"streams.group.mqtt": "MQTT",
@@ -2970,7 +3016,7 @@
"tour.src.static": "Static Image — test your setup with image files instead of live capture.",
"tour.src.sync": "Sync Clocks — shared timers that synchronize animations across multiple sources.",
"tour.src.templates": "Capture Templates — reusable capture configurations (resolution, FPS, crop).",
"tour.src.value": "Value Sources — dynamic numbers or colors driven by time of day, audio, system metrics, Home Assistant entities, gradients, or schedules. Used to animate effects, modulate color strips, and trigger automations.",
"tour.src.value": "Value Sources — dynamic numbers or colors driven by time of day, audio, system metrics, Home Assistant entities, palettes, or schedules. Used to animate effects, modulate color strips, and trigger automations.",
"tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.",
"tour.tgt.css": "Color Strips — define how screen regions map to LED segments.",
"tour.tgt.devices": "Devices — your LED controllers discovered on the network.",
+58 -9
View File
@@ -40,7 +40,10 @@
"activity_log.filter.since": "С",
"activity_log.filter.title": "Фильтры",
"activity_log.filter.until": "По",
"activity_log.live": "Live",
"activity_log.live": "В эфире",
"z2m_light.bulbs.one": "{count} лампа",
"z2m_light.bulbs.few": "{count} лампы",
"z2m_light.bulbs.many": "{count} ламп",
"activity_log.load_more": "Загрузить ещё",
"activity_log.loading": "Загрузка журнала…",
"activity_log.n_entries": "Записей: {n}",
@@ -75,6 +78,7 @@
"activity_log.msg.audit_log.disabled": "Запись активности отключена",
"activity_log.msg.automation.activated": "Автоматизация '{name}' активирована",
"activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована",
"activity_log.msg.automation.triggered": "Автоматизация '{name}' запущена вручную",
"activity_log.msg.server.shutting_down": "Сервер выключается",
"activity_log.msg.server.restarting": "Запрошен перезапуск сервера",
"activity_log.msg.server.shutdown_requested": "Запрошено выключение сервера",
@@ -96,7 +100,7 @@
"activity_log.entity_type.scene_playlist": "Плейлист сцен",
"activity_log.entity_type.sync_clock": "Синх-часы",
"activity_log.entity_type.template": "Шаблон",
"activity_log.entity_type.gradient": "Градиент",
"activity_log.entity_type.gradient": "Палитра",
"activity_log.entity_type.cspt": "Шаблон обработки",
"activity_log.entity_type.audio_template": "Аудиошаблон",
"activity_log.entity_type.audio_processing_template": "Шаблон аудиообработки",
@@ -284,8 +288,8 @@
"auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к LED Grab.",
"auth.placeholder": "Введите ваш API ключ...",
"auth.please_login": "Пожалуйста, войдите для просмотра",
"auth.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"auth.prompt_enter": "Введите ваш API-ключ:",
"auth.prompt_update": "API-ключ уже задан. Введите новый ключ для замены или оставьте поле пустым, чтобы удалить:",
"auth.session_expired": "Ваша сессия истекла или API ключ недействителен. Пожалуйста, войдите снова.",
"auth.success": "Вход выполнен успешно!",
"auth.title": "Вход в LED Grab",
@@ -336,6 +340,7 @@
"automation.disabled": "Автоматизация выключена",
"automation.enabled": "Автоматизация включена",
"automations.action.disable": "Отключить",
"automations.action.trigger": "Запустить",
"automations.add": "Добавить автоматизацию",
"automations.created": "Автоматизация создана",
"automations.deactivation_mode": "Деактивация:",
@@ -360,6 +365,7 @@
"automations.error.name_required": "Введите название",
"automations.error.save_failed": "Не удалось сохранить автоматизацию",
"automations.error.toggle_failed": "Не удалось переключить автоматизацию",
"automations.error.trigger_failed": "Не удалось запустить автоматизацию",
"automations.last_activated": "Последняя активация",
"automations.logic.all": "ВСЕ",
"automations.logic.and": " И ",
@@ -417,6 +423,9 @@
"automations.rule.http_poll.value": "Значение",
"automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.value_source": "HTTP источник-значения",
"automations.rule.manual_trigger": "Ручной запуск",
"automations.rule.manual_trigger.desc": "Запуск по кнопке",
"automations.rule.manual_trigger.hint": "Активируется только при нажатии кнопки «Запустить» на карточке автоматизации (остальные правила автоматизации по-прежнему проверяются).",
"automations.rule.mqtt": "MQTT",
"automations.rule.mqtt.desc": "MQTT сообщение",
"automations.rule.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
@@ -437,6 +446,18 @@
"automations.rule.system_idle.when_active.desc": "Срабатывает, пока пользователь активно работает с системой",
"automations.rule.system_idle.when_idle": "При бездействии",
"automations.rule.system_idle.when_idle.desc": "Срабатывает, когда пользователь не активен дольше тайм-аута",
"automations.rule.solar": "Солнце (восход / закат)",
"automations.rule.solar.desc": "Относительно восхода/заката",
"automations.rule.solar.hint": "Активируется в окне относительно восхода и заката в вашем расположении. По умолчанию — от заката до восхода — это режим «активно ночью».",
"automations.rule.solar.start": "Окно открывается в",
"automations.rule.solar.end": "Окно закрывается в",
"automations.rule.solar.event.sunrise": "Восход",
"automations.rule.solar.event.sunset": "Закат",
"automations.rule.solar.offset": "Смещение в минутах (отрицательное — до события, положительное — после)",
"automations.rule.solar.offset.unit": "мин",
"automations.rule.solar.latitude": "Широта",
"automations.rule.solar.longitude": "Долгота",
"automations.rule.solar.location_hint": "Укажите широту и долготу, чтобы восход и закат вычислялись для вашего расположения.",
"automations.rule.time_of_day": "Время суток",
"automations.rule.time_of_day.days": "Активные дни",
"automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.",
@@ -475,6 +496,10 @@
"automations.status.disabled": "Отключена",
"automations.status.inactive": "Неактивна",
"automations.title": "Автоматизации",
"automations.trigger.partial": "Запущено с ошибками",
"automations.trigger.skipped": "Условия не выполнены — автоматизация не запущена",
"automations.trigger.tooltip": "Запустить эту автоматизацию сейчас (правила по-прежнему проверяются)",
"automations.triggered": "Автоматизация запущена",
"automations.updated": "Автоматизация обновлена",
"bg.anim.toggle": "Анимированный фон",
"bindable.none": "Нет (статическое значение)",
@@ -516,6 +541,10 @@
"calibration.advanced.switch_to_simple": "Простой режим",
"calibration.advanced.title": "Расширенная калибровка",
"calibration.border_width": "Граница (px):",
"calibration.linear_blend": "Смешивание в линейном свете",
"calibration.linear_blend.hint": "Усреднять пиксели границы в линейном свете для перцептивно корректного, более яркого смешивания цветов.",
"calibration.dither": "Дизеринг",
"calibration.dither.hint": "Пространственно-временной дизеринг уменьшает заметные полосы на плавных градиентах.",
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
"calibration.button.cancel": "Отмена",
"calibration.button.save": "Сохранить",
@@ -728,6 +757,14 @@
"color_strip.effect.meteor": "Метеор",
"color_strip.effect.meteor.desc": "Яркая точка движется по ленте с экспоненциально затухающим хвостом",
"color_strip.effect.mirror": "Отражение:",
"color_strip.effect.reactive": "Реакция на звук:",
"color_strip.effect.reactive.hint": "Модулировать яркость и/или насыщенность этого эффекта по громкости звука в реальном времени.",
"color_strip.effect.reactive.source": "Источник звука:",
"color_strip.effect.reactive.mode": "Модуляция:",
"color_strip.effect.reactive.mode.brightness": "Яркость",
"color_strip.effect.reactive.mode.saturation": "Насыщенность",
"color_strip.effect.reactive.mode.both": "Обе",
"color_strip.effect.reactive.intensity": "Сила:",
"color_strip.effect.mirror.hint": "Режим отскока — метеор меняет направление у краёв ленты вместо переноса.",
"color_strip.effect.noise": "Шум",
"color_strip.effect.noise.desc": "Прокручиваемый фрактальный шум, отображённый на палитру",
@@ -760,7 +797,7 @@
"color_strip.gamma.hint": "Гамма-коррекция (1=без коррекции, \u003c1=ярче средние тона, \u003e1=темнее средние тона)",
"color_strip.gradient.add_stop": "+ Добавить",
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок",
"color_strip.gradient.min_stops": "Палитра должна содержать не менее 2 остановок",
"color_strip.gradient.position": "Позиция (0.01.0)",
"color_strip.gradient.preset": "Пресет:",
"color_strip.gradient.preset.apply": "Применить",
@@ -784,6 +821,15 @@
"color_strip.gradient.preset.warm": "Тёплый",
"color_strip.gradient.preview": "Градиент:",
"color_strip.gradient.preview.hint": "Предпросмотр градиента. Нажмите на дорожку маркеров чтобы добавить остановку. Перетащите маркеры для изменения позиции.",
"color_strip.gradient.harmony": "Цветовая гармония:",
"color_strip.gradient.harmony.hint": "Выберите базовый цвет, затем создайте гармоничный набор точек на основе классических цветовых сочетаний.",
"color_strip.gradient.harmony.base": "Базовый цвет",
"color_strip.gradient.harmony.complementary": "Контрастная",
"color_strip.gradient.harmony.analogous": "Аналоговая",
"color_strip.gradient.harmony.triadic": "Триада",
"color_strip.gradient.harmony.split_complementary": "Разделённая",
"color_strip.gradient.harmony.tetradic": "Тетрада",
"color_strip.gradient.harmony.monochromatic": "Моно",
"color_strip.gradient.stops": "Цветовые остановки:",
"color_strip.gradient.stops.hint": "Каждая остановка задаёт цвет в относительной позиции (0.0 = начало, 1.0 = конец). Кнопка ↔ добавляет цвет справа для создания резкого перехода.",
"color_strip.gradient.stops_count": "остановок",
@@ -1290,7 +1336,7 @@
"device.icon.entity.cspt": "Шаблон обработки полоски",
"device.icon.entity.device": "Устройство",
"device.icon.entity.game_integration": "Игровая интеграция",
"device.icon.entity.gradient": "Градиент",
"device.icon.entity.gradient": "Палитра",
"device.icon.entity.ha_light_target": "HA-светильник",
"device.icon.entity.ha_source": "Источник Home Assistant",
"device.icon.entity.http_endpoint": "HTTP-эндпоинт",
@@ -1417,6 +1463,8 @@
"device.nanoleaf.url.placeholder": "192.168.1.50",
"device.nanoleaf_min_interval": "Мин. интервал обновления:",
"device.nanoleaf_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 100 мс ≈ 10 Гц; накладные расходы HTTP ограничивают практический максимум ~20 Гц.",
"device.nanoleaf_per_panel": "Потоковая передача по панелям:",
"device.nanoleaf_per_panel.hint": "Передавать цвет каждой панели отдельно через extControl UDP вместо одного усреднённого цвета. Требуется недавняя прошивка контроллера.",
"device.opc.url": "IP-адрес:",
"device.opc.url.hint": "Адрес приёмника OPC. TCP-порт по умолчанию 7890.",
"device.opc.url.placeholder": "192.168.1.50",
@@ -1706,8 +1754,8 @@
"game_integration.test.timeout": "События не получены за отведённое время.",
"game_integration.test.waiting": "Ожидание событий от игры...",
"game_integration.updated": "Игровая интеграция обновлена",
"gradient.error.delete_failed": "Не удалось удалить градиент",
"gradient.error.save_failed": "Не удалось сохранить градиент",
"gradient.error.delete_failed": "Не удалось удалить палитру",
"gradient.error.save_failed": "Не удалось сохранить палитру",
"graph.action.connect": "Соединить",
"graph.action.disconnect": "Отсоединить",
"graph.action.move": "Переместить узел",
@@ -2403,7 +2451,7 @@
"settings.section.file": "Файл",
"settings.section.filtering": "Фильтрация",
"settings.section.filters": "Фильтры",
"settings.section.gradient": "Градиент",
"settings.section.gradient": "Палитра",
"settings.section.hardware": "Оборудование",
"settings.section.history": "История",
"settings.section.identity": "Идентификация",
@@ -2419,6 +2467,7 @@
"settings.section.notif_permission": "Разрешение ОС",
"settings.section.offsets": "Смещения",
"settings.section.output": "Вывод",
"settings.section.power": "Питание",
"settings.section.preview": "Превью",
"settings.section.protocol": "Протокол",
"settings.section.provider": "Провайдер",
+56 -8
View File
@@ -41,6 +41,8 @@
"activity_log.filter.title": "过滤",
"activity_log.filter.until": "至",
"activity_log.live": "实时",
"z2m_light.bulbs.one": "{count} 个灯泡",
"z2m_light.bulbs.other": "{count} 个灯泡",
"activity_log.load_more": "加载更多",
"activity_log.loading": "正在加载活动日志…",
"activity_log.n_entries": "{n} 条记录",
@@ -75,6 +77,7 @@
"activity_log.msg.audit_log.disabled": "活动记录已禁用",
"activity_log.msg.automation.activated": "自动化 '{name}' 已激活",
"activity_log.msg.automation.deactivated": "自动化 '{name}' 已停用",
"activity_log.msg.automation.triggered": "已手动触发自动化 '{name}'",
"activity_log.msg.server.shutting_down": "服务器正在关闭",
"activity_log.msg.server.restarting": "已请求服务器重启",
"activity_log.msg.server.shutdown_requested": "已请求服务器关闭",
@@ -96,7 +99,7 @@
"activity_log.entity_type.scene_playlist": "场景播放列表",
"activity_log.entity_type.sync_clock": "同步时钟",
"activity_log.entity_type.template": "模板",
"activity_log.entity_type.gradient": "渐变",
"activity_log.entity_type.gradient": "调色板",
"activity_log.entity_type.cspt": "处理模板",
"activity_log.entity_type.audio_template": "音频模板",
"activity_log.entity_type.audio_processing_template": "音频处理模板",
@@ -284,8 +287,8 @@
"auth.message": "请输入 API 密钥以进行身份验证并访问 LED Grab。",
"auth.placeholder": "输入您的 API 密钥...",
"auth.please_login": "请先登录",
"auth.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"auth.prompt_enter": "请输入您的 API 密钥:",
"auth.prompt_update": "已设置 API 密钥。输入新密钥以更新,或留空以移除:",
"auth.session_expired": "会话已过期或 API 密钥无效,请重新登录。",
"auth.success": "登录成功!",
"auth.title": "登录 LED Grab",
@@ -336,6 +339,7 @@
"automation.disabled": "自动化已禁用",
"automation.enabled": "自动化已启用",
"automations.action.disable": "禁用",
"automations.action.trigger": "触发",
"automations.add": "添加自动化",
"automations.created": "自动化已创建",
"automations.deactivation_mode": "停用方式:",
@@ -360,6 +364,7 @@
"automations.error.name_required": "名称为必填项",
"automations.error.save_failed": "保存自动化失败",
"automations.error.toggle_failed": "切换自动化失败",
"automations.error.trigger_failed": "触发自动化失败",
"automations.last_activated": "上次激活",
"automations.logic.all": "全部",
"automations.logic.and": " 与 ",
@@ -417,6 +422,9 @@
"automations.rule.http_poll.value": "值",
"automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.value_source": "HTTP 值源",
"automations.rule.manual_trigger": "手动触发",
"automations.rule.manual_trigger.desc": "通过按钮运行",
"automations.rule.manual_trigger.hint": "仅当您点击自动化卡片上的“触发”按钮时激活(仍会检查该自动化的其他规则)。",
"automations.rule.mqtt": "MQTT",
"automations.rule.mqtt.desc": "MQTT 消息",
"automations.rule.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
@@ -437,6 +445,18 @@
"automations.rule.system_idle.when_active.desc": "当用户正在使用系统时触发",
"automations.rule.system_idle.when_idle": "空闲时",
"automations.rule.system_idle.when_idle.desc": "当用户空闲时间超过超时阈值后触发",
"automations.rule.solar": "太阳(日出 / 日落)",
"automations.rule.solar.desc": "相对于日出/日落",
"automations.rule.solar.hint": "在所在位置的日出和日落相关的时间窗口内激活。默认从日落到日出,即「夜间生效」。",
"automations.rule.solar.start": "窗口开始于",
"automations.rule.solar.end": "窗口结束于",
"automations.rule.solar.event.sunrise": "日出",
"automations.rule.solar.event.sunset": "日落",
"automations.rule.solar.offset": "偏移分钟数(负值=事件之前,正值=事件之后)",
"automations.rule.solar.offset.unit": "分钟",
"automations.rule.solar.latitude": "纬度",
"automations.rule.solar.longitude": "经度",
"automations.rule.solar.location_hint": "请设置纬度和经度,以便按所在位置计算日出和日落。",
"automations.rule.time_of_day": "时段",
"automations.rule.time_of_day.days": "生效日期",
"automations.rule.time_of_day.days_hint": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。",
@@ -475,6 +495,10 @@
"automations.status.disabled": "已禁用",
"automations.status.inactive": "非活动",
"automations.title": "自动化",
"automations.trigger.partial": "触发时出现错误",
"automations.trigger.skipped": "条件不满足 — 未触发自动化",
"automations.trigger.tooltip": "立即运行此自动化(仍会检查其规则)",
"automations.triggered": "已触发自动化",
"automations.updated": "自动化已更新",
"bg.anim.toggle": "切换动态背景",
"bindable.none": "无(静态值)",
@@ -514,6 +538,10 @@
"calibration.advanced.switch_to_simple": "切换到简单模式",
"calibration.advanced.title": "高级校准",
"calibration.border_width": "边框(像素):",
"calibration.linear_blend": "线性光混合",
"calibration.linear_blend.hint": "在线性光空间中对边框像素求平均,以获得感知正确、更明亮的颜色混合。",
"calibration.dither": "抖动",
"calibration.dither.hint": "时空抖动可减少平滑渐变上可见的色带。",
"calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100",
"calibration.button.cancel": "取消",
"calibration.button.save": "保存",
@@ -726,6 +754,14 @@
"color_strip.effect.meteor": "流星",
"color_strip.effect.meteor.desc": "明亮头部沿灯带移动,带指数衰减的尾迹",
"color_strip.effect.mirror": "镜像:",
"color_strip.effect.reactive": "音频反应:",
"color_strip.effect.reactive.hint": "根据实时音频响度调节此效果的亮度和/或饱和度。",
"color_strip.effect.reactive.source": "音频源:",
"color_strip.effect.reactive.mode": "调节:",
"color_strip.effect.reactive.mode.brightness": "亮度",
"color_strip.effect.reactive.mode.saturation": "饱和度",
"color_strip.effect.reactive.mode.both": "两者",
"color_strip.effect.reactive.intensity": "强度:",
"color_strip.effect.mirror.hint": "反弹模式 — 流星在灯带末端反转方向而不是循环。",
"color_strip.effect.noise": "噪声",
"color_strip.effect.noise.desc": "滚动的分形值噪声映射到调色板",
@@ -758,7 +794,7 @@
"color_strip.gamma.hint": "伽马校正(1=无,\u003c1=更亮的中间调,\u003e1=更暗的中间调)",
"color_strip.gradient.add_stop": "+ 添加色标",
"color_strip.gradient.bidir.hint": "在此色标右侧添加第二种颜色以在渐变中创建硬边。",
"color_strip.gradient.min_stops": "渐变至少需要 2 个色标",
"color_strip.gradient.min_stops": "调色板至少需要 2 个色标",
"color_strip.gradient.position": "位置(0.0-1.0",
"color_strip.gradient.preset": "预设:",
"color_strip.gradient.preset.apply": "应用",
@@ -782,6 +818,15 @@
"color_strip.gradient.preset.warm": "暖色",
"color_strip.gradient.preview": "渐变:",
"color_strip.gradient.preview.hint": "可视预览。点击下方标记轨道添加色标。拖动标记重新定位。",
"color_strip.gradient.harmony": "配色和谐:",
"color_strip.gradient.harmony.hint": "选择一个基色,然后根据经典配色关系生成一组和谐的色标。",
"color_strip.gradient.harmony.base": "基色",
"color_strip.gradient.harmony.complementary": "互补色",
"color_strip.gradient.harmony.analogous": "邻近色",
"color_strip.gradient.harmony.triadic": "三色",
"color_strip.gradient.harmony.split_complementary": "分裂互补",
"color_strip.gradient.harmony.tetradic": "四色",
"color_strip.gradient.harmony.monochromatic": "单色",
"color_strip.gradient.stops": "色标:",
"color_strip.gradient.stops.hint": "每个色标在相对位置定义一种颜色(0.0 = 起始,1.0 = 结束)。↔ 按钮添加右侧颜色以在该色标处创建硬边。",
"color_strip.gradient.stops_count": "个色标",
@@ -1288,7 +1333,7 @@
"device.icon.entity.cspt": "色带处理模板",
"device.icon.entity.device": "设备",
"device.icon.entity.game_integration": "游戏集成",
"device.icon.entity.gradient": "渐变",
"device.icon.entity.gradient": "调色板",
"device.icon.entity.ha_light_target": "HA 灯目标",
"device.icon.entity.ha_source": "Home Assistant 源",
"device.icon.entity.http_endpoint": "HTTP 端点",
@@ -1415,6 +1460,8 @@
"device.nanoleaf.url.placeholder": "192.168.1.50",
"device.nanoleaf_min_interval": "最小更新间隔:",
"device.nanoleaf_min_interval.hint": "客户端命令速率限制(毫秒)。默认 100 毫秒 ≈ 10 Hz;HTTP 请求开销将实际上限限制在约 20 Hz。",
"device.nanoleaf_per_panel": "逐面板流式传输:",
"device.nanoleaf_per_panel.hint": "通过 extControl UDP 单独传输每个面板的颜色,而不是单一平均色。需要较新的控制器固件。",
"device.opc.url": "IP 地址:",
"device.opc.url.hint": "OPC 接收器地址。TCP 端口默认为 7890。",
"device.opc.url.placeholder": "192.168.1.50",
@@ -1704,8 +1751,8 @@
"game_integration.test.timeout": "在超时期间内未收到事件。",
"game_integration.test.waiting": "等待游戏事件...",
"game_integration.updated": "游戏集成已更新",
"gradient.error.delete_failed": "删除渐变失败",
"gradient.error.save_failed": "保存渐变失败",
"gradient.error.delete_failed": "删除调色板失败",
"gradient.error.save_failed": "保存调色板失败",
"graph.action.connect": "连接",
"graph.action.disconnect": "断开连接",
"graph.action.move": "移动节点",
@@ -2397,7 +2444,7 @@
"settings.section.file": "文件",
"settings.section.filtering": "过滤",
"settings.section.filters": "过滤器",
"settings.section.gradient": "渐变",
"settings.section.gradient": "调色板",
"settings.section.hardware": "硬件",
"settings.section.history": "历史",
"settings.section.identity": "标识",
@@ -2413,6 +2460,7 @@
"settings.section.notif_permission": "系统权限",
"settings.section.offsets": "偏移",
"settings.section.output": "输出",
"settings.section.power": "电源",
"settings.section.preview": "预览",
"settings.section.protocol": "协议",
"settings.section.provider": "提供商",
@@ -20,7 +20,7 @@ Design notes
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
from typing import Iterator
from ledgrab.storage.activity_log import ActivityLogEntry, ActivityLogFilters
@@ -32,6 +32,21 @@ logger = get_logger(__name__)
_TABLE = "activity_log"
def _to_utc_iso(dt: datetime) -> str:
"""Serialise *dt* to a canonical UTC ISO-8601 string for ``ts`` comparison.
Stored ``ts`` values are always written UTC-aware (``+00:00``). An incoming
filter datetime may be naive (e.g. a ``datetime-local`` value with no
offset interpreted here as UTC) or carry a non-UTC offset. Both must be
normalised to UTC so the lexicographic ``ts >= ?`` / ``ts <= ?`` string
comparison SQLite performs on the TEXT column is chronologically correct;
otherwise boundary rows and offset-shifted rows are mis-classified.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat()
def _build_filter_clause(
filters: ActivityLogFilters,
params: list,
@@ -77,11 +92,11 @@ def _build_filter_clause(
if filters.since is not None:
conditions.append("ts >= ?")
params.append(filters.since.isoformat())
params.append(_to_utc_iso(filters.since))
if filters.until is not None:
conditions.append("ts <= ?")
params.append(filters.until.isoformat())
params.append(_to_utc_iso(filters.until))
if filters.message_like is not None:
# Escape LIKE special characters in the user-supplied substring so that
@@ -149,6 +164,42 @@ class ActivityLogRepository:
# -- Read ----------------------------------------------------------------
def query_with_seq(
self,
filters: ActivityLogFilters,
*,
before_seq: int | None = None,
limit: int = 50,
) -> list[tuple[int, ActivityLogEntry]]:
"""Like :meth:`query` but returns ``(seq, entry)`` tuples.
Lets the list endpoint read the keyset cursor (``next_before_seq``)
straight from the oldest in-page row instead of issuing a second
``get_seq_for_id`` round-trip the ``seq`` is already fetched here.
"""
params: list = []
keyset = "seq < ?" if before_seq is not None else None
if before_seq is not None:
params.append(before_seq)
where_fragment = _build_filter_clause(filters, params, extra_where=keyset)
where_clause = f"WHERE {where_fragment}" if where_fragment else ""
params.append(limit)
sql = (
f"SELECT seq, id, ts, category, action, severity, actor, "
f"entity_type, entity_id, entity_name, message, metadata "
f"FROM {_TABLE} "
f"{where_clause} "
f"ORDER BY seq DESC "
f"LIMIT ?"
)
cursor = self._db.execute(sql, tuple(params))
rows = cursor.fetchall()
# Reverse to return chronological order within the page
return [(int(row["seq"]), ActivityLogEntry.from_row(dict(row))) for row in reversed(rows)]
def query(
self,
filters: ActivityLogFilters,
@@ -173,28 +224,10 @@ class ActivityLogRepository:
limit:
Maximum number of entries to return.
"""
params: list = []
keyset = "seq < ?" if before_seq is not None else None
if before_seq is not None:
params.append(before_seq)
where_fragment = _build_filter_clause(filters, params, extra_where=keyset)
where_clause = f"WHERE {where_fragment}" if where_fragment else ""
params.append(limit)
sql = (
f"SELECT seq, id, ts, category, action, severity, actor, "
f"entity_type, entity_id, entity_name, message, metadata "
f"FROM {_TABLE} "
f"{where_clause} "
f"ORDER BY seq DESC "
f"LIMIT ?"
)
cursor = self._db.execute(sql, tuple(params))
rows = cursor.fetchall()
# Reverse to return chronological order within the page
return [ActivityLogEntry.from_row(dict(row)) for row in reversed(rows)]
return [
entry
for _seq, entry in self.query_with_seq(filters, before_seq=before_seq, limit=limit)
]
def count(self, filters: ActivityLogFilters | None = None) -> int:
"""Return the number of entries matching *filters* (or all entries)."""
@@ -233,7 +266,7 @@ class ActivityLogRepository:
if before_ts is not None:
cursor = self._db.execute(
f"DELETE FROM {_TABLE} WHERE ts < ?",
(before_ts.isoformat(),),
(_to_utc_iso(before_ts),),
)
deleted += cursor.rowcount
@@ -321,8 +354,7 @@ class ActivityLogRepository:
)
# Hold the lock only for the bounded fetchall; release before yielding.
with self._db._lock: # noqa: SLF001 — internal access; no public cursor API
rows = self._db._conn.execute(sql, tuple(params)).fetchall() # noqa: SLF001
rows = self._db.fetch_under_lock(sql, tuple(params))
if not rows:
break
+93
View File
@@ -102,6 +102,80 @@ class TimeOfDayRule(Rule):
)
@dataclass
class SolarRule(Rule):
"""Activate during a window defined relative to sunrise / sunset.
The window runs from ``start_event`` (+``start_offset_minutes``) to
``end_event`` (+``end_offset_minutes``), where each event is either
``"sunrise"`` or ``"sunset"`` for the rule's location and the current day.
The default sunset sunrise is the common "active at night" case.
Offsets may be negative (before the event) or positive (after), clamped to
±1439 minutes. ``latitude``/``longitude`` are per-rule (mirrors the
daylight value source); ``timezone`` is an IANA name (empty = server local,
same as :class:`TimeOfDayRule`). ``days_of_week`` (0=Mon..6=Sun, empty =
every day) restricts which days the window is active, with the same
overnight attribution as ``TimeOfDayRule`` (a wrapped early-morning tail
belongs to the day the window started on).
"""
rule_type: str = "solar"
start_event: str = "sunset" # "sunrise" | "sunset"
start_offset_minutes: int = 0
end_event: str = "sunrise" # "sunrise" | "sunset"
end_offset_minutes: int = 0
latitude: float = 50.0
longitude: float = 0.0
days_of_week: List[int] = field(default_factory=list) # 0=Mon..6=Sun; empty=all days
timezone: str = "" # IANA tz name; empty = server local time
def to_dict(self) -> dict:
d = super().to_dict()
d["start_event"] = self.start_event
d["start_offset_minutes"] = self.start_offset_minutes
d["end_event"] = self.end_event
d["end_offset_minutes"] = self.end_offset_minutes
d["latitude"] = self.latitude
d["longitude"] = self.longitude
d["days_of_week"] = self.days_of_week
d["timezone"] = self.timezone
return d
@classmethod
def from_dict(cls, data: dict) -> "SolarRule":
def _event(key: str, default: str) -> str:
v = data.get(key, default)
return v if v in ("sunrise", "sunset") else default
def _offset(key: str) -> int:
try:
return max(-1439, min(1439, int(data.get(key, 0))))
except (TypeError, ValueError):
return 0
def _coord(key: str, lo: float, hi: float, default: float) -> float:
try:
return max(lo, min(hi, float(data.get(key, default))))
except (TypeError, ValueError):
return default
raw_days = data.get("days_of_week") or []
days = sorted(
{int(d) for d in raw_days if isinstance(d, (int, float)) and 0 <= int(d) <= 6}
)
return cls(
start_event=_event("start_event", "sunset"),
start_offset_minutes=_offset("start_offset_minutes"),
end_event=_event("end_event", "sunrise"),
end_offset_minutes=_offset("end_offset_minutes"),
latitude=_coord("latitude", -90.0, 90.0, 50.0),
longitude=_coord("longitude", -180.0, 180.0, 0.0),
days_of_week=days,
timezone=data.get("timezone", "") or "",
)
@dataclass
class SystemIdleRule(Rule):
"""Activate based on system idle time (keyboard/mouse inactivity)."""
@@ -199,6 +273,23 @@ class StartupRule(Rule):
return cls()
@dataclass
class ManualTriggerRule(Rule):
"""Activate via an explicit manual trigger (UI "Trigger" button / API call).
Zero-config, like ``StartupRule``. It evaluates to True only while an
automation is being manually fired (see ``AutomationEngine.fire_manual_trigger``);
during the background evaluation tick it always reads False, so a
manual-trigger automation never activates on its own.
"""
rule_type: str = "manual_trigger"
@classmethod
def from_dict(cls, data: dict) -> "ManualTriggerRule":
return cls()
@dataclass
class HomeAssistantRule(Rule):
"""Activate based on a Home Assistant entity state."""
@@ -273,11 +364,13 @@ class HTTPPollRule(Rule):
_RULE_MAP: Dict[str, Type[Rule]] = {
"application": ApplicationRule,
"time_of_day": TimeOfDayRule,
"solar": SolarRule,
"system_idle": SystemIdleRule,
"display_state": DisplayStateRule,
"mqtt": MQTTRule,
"webhook": WebhookRule,
"startup": StartupRule,
"manual_trigger": ManualTriggerRule,
"home_assistant": HomeAssistantRule,
"http_poll": HTTPPollRule,
# Legacy: "always" maps to StartupRule for migration
@@ -544,6 +544,12 @@ class EffectColorStripSource(ColorStripSource):
scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
mirror: bool = False # bounce mode (meteor/comet)
custom_palette: list | None = None # legacy [[pos, R, G, B], ...] custom palette stops
# Audio-reactive modulation: live loudness from a referenced AudioSource
# modulates the rendered output (brightness and/or saturation).
audio_reactive: bool = False
reactive_audio_source_id: str = "" # references an AudioSource for loudness
reactive_mode: str = "brightness" # brightness | saturation | both
reactive_intensity: BindableFloat = field(default_factory=lambda: BindableFloat(0.7))
def to_dict(self) -> dict:
d = super().to_dict()
@@ -555,6 +561,10 @@ class EffectColorStripSource(ColorStripSource):
d["scale"] = self.scale.to_dict()
d["mirror"] = self.mirror
d["custom_palette"] = self.custom_palette
d["audio_reactive"] = self.audio_reactive
d["reactive_audio_source_id"] = self.reactive_audio_source_id
d["reactive_mode"] = self.reactive_mode
d["reactive_intensity"] = self.reactive_intensity.to_dict()
return d
@classmethod
@@ -571,6 +581,10 @@ class EffectColorStripSource(ColorStripSource):
scale=BindableFloat.from_raw(data.get("scale"), default=1.0),
mirror=bool(data.get("mirror", False)),
custom_palette=data.get("custom_palette"),
audio_reactive=bool(data.get("audio_reactive", False)),
reactive_audio_source_id=data.get("reactive_audio_source_id") or "",
reactive_mode=data.get("reactive_mode") or "brightness",
reactive_intensity=BindableFloat.from_raw(data.get("reactive_intensity"), default=0.7),
)
@classmethod
@@ -593,6 +607,10 @@ class EffectColorStripSource(ColorStripSource):
scale=None,
mirror=False,
custom_palette=None,
audio_reactive=False,
reactive_audio_source_id="",
reactive_mode="brightness",
reactive_intensity=None,
**_kwargs,
):
return cls(
@@ -612,6 +630,10 @@ class EffectColorStripSource(ColorStripSource):
scale=BindableFloat.from_raw(scale, default=1.0),
mirror=bool(mirror),
custom_palette=custom_palette if isinstance(custom_palette, list) else None,
audio_reactive=bool(audio_reactive),
reactive_audio_source_id=reactive_audio_source_id or "",
reactive_mode=reactive_mode or "brightness",
reactive_intensity=BindableFloat.from_raw(reactive_intensity, default=0.7),
)
def apply_update(self, **kwargs) -> None:
@@ -633,6 +655,16 @@ class EffectColorStripSource(ColorStripSource):
if "custom_palette" in kwargs:
cp = kwargs["custom_palette"]
self.custom_palette = cp if isinstance(cp, list) else None
if kwargs.get("audio_reactive") is not None:
self.audio_reactive = bool(kwargs["audio_reactive"])
if "reactive_audio_source_id" in kwargs:
self.reactive_audio_source_id = kwargs["reactive_audio_source_id"] or ""
if kwargs.get("reactive_mode") is not None:
self.reactive_mode = kwargs["reactive_mode"]
if kwargs.get("reactive_intensity") is not None:
self.reactive_intensity = self.reactive_intensity.apply_update(
kwargs["reactive_intensity"]
)
@dataclass
+13
View File
@@ -197,6 +197,19 @@ class Database:
self._conn.executemany(sql, params_list)
self._conn.commit()
def fetch_under_lock(self, sql: str, params: Tuple = ()) -> List[sqlite3.Row]:
"""Run a read-only query holding the lock only for this call (no commit).
Used by streaming exporters that must release the lock between batches
instead of holding it across the whole stream. Guards against a closed
connection so a post-close call surfaces a clear ``RuntimeError`` rather
than an ``AttributeError`` on ``None``.
"""
with self._lock:
if self._conn is None:
raise RuntimeError("database is closed")
return self._conn.execute(sql, params).fetchall()
@contextmanager
def transaction(self):
"""Context manager for multi-statement transactions.
@@ -94,6 +94,7 @@ class Device:
# Nanoleaf fields
nanoleaf_token: str = "",
nanoleaf_min_interval_ms: int = 100,
nanoleaf_per_panel: bool = False,
# SPI Direct fields
spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B",
@@ -148,6 +149,7 @@ class Device:
self.opc_channel = opc_channel
self.nanoleaf_token = nanoleaf_token
self.nanoleaf_min_interval_ms = nanoleaf_min_interval_ms
self.nanoleaf_per_panel = nanoleaf_per_panel
self.spi_speed_hz = spi_speed_hz
self.spi_led_type = spi_led_type
self.chroma_device_type = chroma_device_type
@@ -270,6 +272,7 @@ class Device:
**base,
nanoleaf_token=self.nanoleaf_token,
nanoleaf_min_interval_ms=self.nanoleaf_min_interval_ms,
nanoleaf_per_panel=self.nanoleaf_per_panel,
)
if dt == "spi":
return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
@@ -364,6 +367,8 @@ class Device:
d["nanoleaf_token"] = _enc(self.nanoleaf_token)
if self.nanoleaf_min_interval_ms != 100:
d["nanoleaf_min_interval_ms"] = self.nanoleaf_min_interval_ms
if self.nanoleaf_per_panel:
d["nanoleaf_per_panel"] = True
if self.spi_speed_hz != 800000:
d["spi_speed_hz"] = self.spi_speed_hz
if self.spi_led_type != "WS2812B":
@@ -426,6 +431,7 @@ class Device:
opc_channel=data.get("opc_channel", 0),
nanoleaf_token=_dec(data.get("nanoleaf_token", "")),
nanoleaf_min_interval_ms=data.get("nanoleaf_min_interval_ms", 100),
nanoleaf_per_panel=bool(data.get("nanoleaf_per_panel", False)),
spi_speed_hz=data.get("spi_speed_hz", 800000),
spi_led_type=data.get("spi_led_type", "WS2812B"),
chroma_device_type=data.get("chroma_device_type", "chromalink"),
@@ -480,6 +486,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
"opc_channel",
"nanoleaf_token",
"nanoleaf_min_interval_ms",
"nanoleaf_per_panel",
"spi_speed_hz",
"spi_led_type",
"chroma_device_type",
@@ -587,6 +594,7 @@ class DeviceStore(BaseSqliteStore[Device]):
opc_channel: int = 0,
nanoleaf_token: str = "",
nanoleaf_min_interval_ms: int = 100,
nanoleaf_per_panel: bool = False,
spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B",
chroma_device_type: str = "chromalink",
@@ -637,6 +645,7 @@ class DeviceStore(BaseSqliteStore[Device]):
opc_channel=opc_channel,
nanoleaf_token=nanoleaf_token,
nanoleaf_min_interval_ms=nanoleaf_min_interval_ms,
nanoleaf_per_panel=nanoleaf_per_panel,
spi_speed_hz=spi_speed_hz,
spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type,
+18 -4
View File
@@ -10,7 +10,9 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, List
from ledgrab.utils import secret_box
from ledgrab.utils import get_logger, secret_box
logger = get_logger(__name__)
# Keys inside ``adapter_config`` that hold secrets and must be encrypted at
# rest (and decrypted on load). Mirrors the secret_box pattern used by
@@ -37,8 +39,14 @@ def _decrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
Migration-safe: ``secret_box.decrypt`` returns the input unchanged when it
is not an encryption envelope, so legacy plaintext rows still load. On a
corrupt/undecryptable envelope, fall back to dropping the value rather than
crashing the whole store load.
corrupt/undecryptable envelope (e.g. ``data/.secret_key`` was lost/rotated,
or the DB was restored to a machine without the key), PRESERVE the original
encrypted envelope rather than discarding it. Discarding it would let a
later write-through save overwrite the recoverable ciphertext with an empty
string, permanently destroying the secret (silent data loss). Keeping the
envelope means the token is unreadable now but recoverable once the correct
key is restored. A still-encrypted value never matches a real token, so the
adapter simply rejects requests until the secret is recovered/re-entered.
"""
result = dict(adapter_config)
for key in _SECRET_CONFIG_KEYS:
@@ -48,7 +56,13 @@ def _decrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
try:
result[key] = secret_box.decrypt(value)
except Exception:
result[key] = ""
logger.warning(
"Could not decrypt %r for a game integration (secret key "
"missing/rotated?); preserving the encrypted value. Restore "
"data/.secret_key or re-enter the secret to recover.",
key,
)
# Leave result[key] = value (the intact envelope) untouched.
# else: legacy plaintext — leave as-is (migration-safe read path).
return result
+32 -7
View File
@@ -14,7 +14,9 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Dict, List
from ledgrab.utils import secret_box
from ledgrab.utils import get_logger, secret_box
logger = get_logger(__name__)
def _parse_common(data: dict) -> dict:
@@ -58,19 +60,36 @@ class HTTPEndpoint:
icon_color: str = ""
def __post_init__(self) -> None:
# Invariant: ``self.auth_token`` is always plaintext at runtime.
# If a caller constructed this from a raw dict that still holds the
# encrypted envelope, decrypt now so ``build_request_headers``
# Invariant: ``self.auth_token`` is plaintext at runtime when the key is
# available. If a caller constructed this from a raw dict that still
# holds the encrypted envelope, decrypt now so ``build_request_headers``
# doesn't accidentally send ``Authorization: Bearer <envelope>``.
# On decrypt failure (secret key missing/rotated) PRESERVE the envelope
# rather than blanking it — blanking would let a later write-through
# save overwrite the recoverable ciphertext with "" (silent data loss).
# A still-encrypted token is treated as absent at request-build time.
if self.auth_token and secret_box.is_encrypted(self.auth_token):
try:
self.auth_token = secret_box.decrypt(self.auth_token)
except Exception:
self.auth_token = ""
logger.warning(
"Could not decrypt HTTP-endpoint auth_token (secret key "
"missing/rotated?); preserving the encrypted value. Restore "
"data/.secret_key or re-enter the token to recover."
)
@property
def plaintext_token(self) -> str:
return secret_box.decrypt(self.auth_token) if self.auth_token else ""
if not self.auth_token:
return ""
# An undecryptable envelope (key lost) must not be sent or surfaced as
# a token — treat it as absent.
if secret_box.is_encrypted(self.auth_token):
try:
return secret_box.decrypt(self.auth_token)
except Exception:
return ""
return self.auth_token
def build_request_headers(self) -> Dict[str, str]:
"""Compose the headers actually sent on a fetch.
@@ -82,7 +101,13 @@ class HTTPEndpoint:
"""
result: Dict[str, str] = dict(self.headers)
already_has_auth = any(k.lower() == "authorization" for k in result)
if self.auth_token and not already_has_auth:
# Never send a still-encrypted envelope as a bearer token (happens only
# when the secret key is missing and decryption failed in __post_init__).
if (
self.auth_token
and not already_has_auth
and not secret_box.is_encrypted(self.auth_token)
):
result["Authorization"] = f"Bearer {self.auth_token}"
return result
@@ -283,6 +283,17 @@
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_min_interval.hint">Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.</small>
<input type="number" id="device-nanoleaf-min-interval" min="0" max="10000" step="10" value="100">
</div>
<div class="form-group" id="device-nanoleaf-per-panel-group" style="display: none;">
<div class="label-row">
<label for="device-nanoleaf-per-panel" data-i18n="device.nanoleaf_per_panel">Per-panel streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_per_panel.hint">Stream each panel individually via extControl UDP instead of one averaged colour. Requires a recent controller firmware.</small>
<label class="settings-toggle">
<input type="checkbox" id="device-nanoleaf-per-panel">
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- ESP-NOW fields -->
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
<div class="label-row">
@@ -194,6 +194,22 @@
<input type="number" id="cal-roi-height" min="1" max="100" value="100">
</div>
</div>
<div class="calibration-linear-row">
<label class="settings-toggle">
<input type="checkbox" id="cal-linear-blend">
<span class="settings-toggle-slider"></span>
</label>
<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>
</section>
@@ -114,10 +114,10 @@
<div id="css-editor-gradient-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-gradient-preset" data-i18n="color_strip.gradient.select">Gradient:</label>
<label for="css-editor-gradient-preset" data-i18n="color_strip.gradient.select">Palette:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.select.hint">Select a gradient from the library. Create and edit gradients in the Gradients tab.</small>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.select.hint">Select a palette from the library. Create and edit palettes in the Palettes tab.</small>
<select id="css-editor-gradient-preset">
</select>
</div>
@@ -214,6 +214,37 @@
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- Audio reactivity -->
<div class="form-group">
<div class="label-row">
<label for="css-editor-effect-audio-reactive" data-i18n="color_strip.effect.reactive">Audio reactive:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.reactive.hint">Modulate this effect's brightness and/or saturation with live audio loudness.</small>
<label class="settings-toggle">
<input type="checkbox" id="css-editor-effect-audio-reactive" onchange="onEffectReactiveToggle()">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div id="css-editor-effect-reactive-group" style="display:none">
<div class="form-group">
<label for="css-editor-effect-reactive-source" data-i18n="color_strip.effect.reactive.source">Audio source:</label>
<select id="css-editor-effect-reactive-source"></select>
</div>
<div class="form-group">
<label for="css-editor-effect-reactive-mode" data-i18n="color_strip.effect.reactive.mode">Modulate:</label>
<select id="css-editor-effect-reactive-mode">
<option value="brightness" data-i18n="color_strip.effect.reactive.mode.brightness">Brightness</option>
<option value="saturation" data-i18n="color_strip.effect.reactive.mode.saturation">Saturation</option>
<option value="both" data-i18n="color_strip.effect.reactive.mode.both">Both</option>
</select>
</div>
<div class="form-group">
<label for="css-editor-effect-reactive-intensity" data-i18n="color_strip.effect.reactive.intensity">Strength:</label>
<input type="range" id="css-editor-effect-reactive-intensity" min="0" max="1" step="0.05" value="0.7">
</div>
</div>
</div>
<!-- Composite-specific fields -->
@@ -312,6 +312,17 @@
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_min_interval.hint">Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.</small>
<input type="number" id="settings-nanoleaf-min-interval" min="0" max="10000" step="10" value="100">
</div>
<div class="form-group" id="settings-nanoleaf-per-panel-group" style="display: none;">
<div class="label-row">
<label for="settings-nanoleaf-per-panel" data-i18n="device.nanoleaf_per_panel">Per-panel streaming:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.nanoleaf_per_panel.hint">Stream each panel individually via extControl UDP instead of one averaged colour. Requires a recent controller firmware.</small>
<label class="settings-toggle">
<input type="checkbox" id="settings-nanoleaf-per-panel">
<span class="settings-toggle-slider"></span>
</label>
</div>
<!-- Read-only pairing indicator. We never show the
token value itself (it's encrypted at rest and
@@ -2,7 +2,7 @@
<div id="gradient-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gradient-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="gradient-editor-title" data-i18n="gradient.add">Add Gradient</h2>
<h2 id="gradient-editor-title" data-i18n="gradient.add">Add Palette</h2>
<button class="modal-close-btn" onclick="closeGradientEditor()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
@@ -22,7 +22,7 @@
<label for="gradient-editor-name" data-i18n="gradient.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="gradient.name.hint">A descriptive name for this gradient.</small>
<small class="input-hint" style="display:none" data-i18n="gradient.name.hint">A descriptive name for this palette.</small>
<input type="text" id="gradient-editor-name" required>
<div id="gradient-editor-tags-container"></div>
</div>
@@ -32,7 +32,7 @@
<label for="gradient-editor-description" data-i18n="gradient.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="gradient.description.hint">Optional description for this gradient.</small>
<small class="input-hint" style="display:none" data-i18n="gradient.description.hint">Optional description for this palette.</small>
<input type="text" id="gradient-editor-description" maxlength="500">
</div>
</div>
@@ -42,7 +42,7 @@
<section class="ds-section" data-ds-key="gradient" data-ch="magenta">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.gradient">Gradient</span>
<span class="ds-section-title" data-i18n="settings.section.gradient">Palette</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
@@ -58,6 +58,25 @@
</div>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.gradient.harmony">Color harmony:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.harmony.hint">Pick a base color, then generate a harmonious set of stops from classic color-theory relationships.</small>
<div class="gradient-harmony-row">
<input type="color" id="ge-gradient-harmony-base" value="#3366ff" data-i18n-title="color_strip.gradient.harmony.base" title="Base color">
<div id="ge-gradient-harmony-types" class="gradient-harmony-types">
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="complementary" data-i18n="color_strip.gradient.harmony.complementary">Complementary</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="analogous" data-i18n="color_strip.gradient.harmony.analogous">Analogous</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="triadic" data-i18n="color_strip.gradient.harmony.triadic">Triadic</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="split_complementary" data-i18n="color_strip.gradient.harmony.split_complementary">Split</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="tetradic" data-i18n="color_strip.gradient.harmony.tetradic">Tetradic</button>
<button type="button" class="btn btn-sm gradient-harmony-btn" data-harmony="monochromatic" data-i18n="color_strip.gradient.harmony.monochromatic">Mono</button>
</div>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.gradient.stops">Color Stops:</label>
@@ -570,7 +570,7 @@
<label for="al-settings-max-days" data-i18n="settings.activity_log.max_days.label">Max age (days)</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.activity_log.max_days.hint">Entries older than this many days are pruned automatically. Set to 0 to disable age-based pruning (keep forever, up to the entry limit).</small>
<small id="al-settings-max-days-hint" class="input-hint" style="display:none" data-i18n="settings.activity_log.max_days.hint">Entries older than this many days are pruned automatically. Set to 0 to disable age-based pruning (keep forever, up to the entry limit).</small>
<input type="number" id="al-settings-max-days" min="0" max="3650" value="365" aria-describedby="al-settings-max-days-hint">
</div>
<div class="form-group">
@@ -578,7 +578,7 @@
<label for="al-settings-max-entries" data-i18n="settings.activity_log.max_entries.label">Max entries</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.activity_log.max_entries.hint">Maximum number of entries to keep. When this limit is reached, the oldest entries are pruned. Set to 0 for no limit.</small>
<small id="al-settings-max-entries-hint" class="input-hint" style="display:none" data-i18n="settings.activity_log.max_entries.hint">Maximum number of entries to keep. When this limit is reached, the oldest entries are pruned. Set to 0 for no limit.</small>
<input type="number" id="al-settings-max-entries" min="0" max="10000000" value="100000" aria-describedby="al-settings-max-entries-hint">
</div>
</div>
@@ -3,8 +3,10 @@
Identity (signal) — name + tags
Routing (cyan) — device + color strip source
Output (amber) — brightness + FPS + advanced (collapsible)
Power (coral) — ABL current budget + per-LED draw
The `<details>` advanced collapse is preserved inside the Output
section. All inner element IDs preserved. -->
section; the power/ABL controls live in their own Power section.
All inner element IDs preserved. -->
<div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title">
<div class="modal-content">
<div class="modal-header">
@@ -139,27 +141,37 @@
<small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
</div>
<div class="form-group" id="target-editor-power-limit-group">
<div class="label-row">
<label for="target-editor-max-milliamps" data-i18n="targets.power_limit">Max current (ABL):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.power_limit.hint">Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.</small>
<div class="label-row">
<input type="number" id="target-editor-max-milliamps" min="0" max="200000" step="100" value="0">
<span data-i18n="targets.power_limit.ma_suffix">mA (0 = unlimited)</span>
</div>
<div class="label-row">
<label for="target-editor-ma-per-led" data-i18n="targets.power_limit.per_led">mA per LED (full white):</label>
<input type="number" id="target-editor-ma-per-led" min="1" max="200" step="1" value="55">
</div>
</div>
</div>
</details>
</div>
</section>
<!-- ── 04 · POWER ──────────────────────────────────── -->
<section class="ds-section" data-ds-key="power" data-ch="coral">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.power">Power</span>
<span class="ds-section-index" aria-hidden="true">04</span>
</div>
<div class="ds-section-body">
<div class="form-group" id="target-editor-power-limit-group">
<div class="label-row">
<label for="target-editor-max-milliamps" data-i18n="targets.power_limit">Max current (ABL):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.power_limit.hint">Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.</small>
<div class="label-row">
<input type="number" id="target-editor-max-milliamps" min="0" max="200000" step="100" value="0">
<span data-i18n="targets.power_limit.ma_suffix">mA (0 = unlimited)</span>
</div>
<div class="label-row">
<label for="target-editor-ma-per-led" data-i18n="targets.power_limit.per_led">mA per LED (full white):</label>
<input type="number" id="target-editor-ma-per-led" min="1" max="200" step="1" value="55">
</div>
</div>
</div>
</section>
<div id="target-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
+39
View File
@@ -0,0 +1,39 @@
"""Spatio-temporal ordered dithering for the final 8-bit quantization.
A smooth gradient quantized to 8 bits per channel bands: adjacent LEDs that
should differ by less than one code round to the same value. Dithering adds a
sub-code threshold that varies per-LED (space) and per-frame (time) before the
floor, so the eye time-averages the strip to a higher effective bit depth
instead of seeing hard steps.
The threshold uses an additive-recurrence (R2 low-discrepancy) sequence: the
per-LED and per-frame phases are irrational multipliers, so the values are
equidistributed in [0, 1) which means ``floor(value + threshold)`` averages
back to ``value`` over time (E[floor(v + U)] = v for the fractional part).
"""
from __future__ import annotations
import numpy as np
# Irrational phase increments (plastic-number / R2 constants) — equidistributed.
_G_LED = 0.7548776662466927
_G_FRAME = 0.5698402909980532
def ordered_dither_quantize(
values: np.ndarray, frame_index: int, led_offset: int = 0
) -> np.ndarray:
"""Quantize float sRGB values in [0, 255] to uint8 with spatio-temporal dither.
``values`` is an ``(N, 3)`` float array. The same per-LED threshold is
applied to all three channels (so hue is preserved); it shifts each frame
via ``frame_index``. ``led_offset`` keeps the spatial phase continuous when
a strip is mapped one edge/segment at a time.
"""
n = values.shape[0]
idx = np.arange(led_offset, led_offset + n, dtype=np.float64)
thr = np.mod(idx * _G_LED + frame_index * _G_FRAME, 1.0).astype(np.float32)[:, None]
out = np.floor(values + thr)
np.clip(out, 0, 255, out=out)
return out.astype(np.uint8)
+48
View File
@@ -0,0 +1,48 @@
"""sRGB ↔ linear-light conversion helpers (LUT-accelerated).
Averaging/blending colours in gamma-encoded sRGB space is perceptually wrong:
the mean of two sRGB values is darker and less saturated than the physically
correct mean of their light intensities. These helpers let the per-LED
reduction blend in linear light and convert back to sRGB for the wire.
Decode is a 256-entry lookup (exact sRGB EOTF); encode is the analytic inverse
applied to the small per-LED result array, so the hot path stays cheap.
"""
from __future__ import annotations
import numpy as np
def _build_srgb_to_linear_lut() -> np.ndarray:
s = np.arange(256, dtype=np.float64) / 255.0
linear = np.where(s <= 0.04045, s / 12.92, ((s + 0.055) / 1.055) ** 2.4)
return linear.astype(np.float32)
# uint8 sRGB index → linear [0, 1] (float32)
SRGB_TO_LINEAR_LUT = _build_srgb_to_linear_lut()
def srgb_to_linear(arr_uint8: np.ndarray) -> np.ndarray:
"""Map a uint8 sRGB array to float32 linear-light in [0, 1] via LUT.
Output shares the input shape; the conversion is per-channel.
"""
return SRGB_TO_LINEAR_LUT[arr_uint8]
def linear_to_srgb_float(linear: np.ndarray) -> np.ndarray:
"""Map float32 linear-light [0, 1] to sRGB float in [0, 255] (no rounding).
Split out from :func:`linear_to_srgb_uint8` so a caller that wants to dither
the final quantization can get the un-rounded sRGB value.
"""
x = np.clip(linear, 0.0, 1.0)
srgb = np.where(x <= 0.0031308, x * 12.92, 1.055 * np.power(x, 1.0 / 2.4) - 0.055)
return (srgb * 255.0).astype(np.float32)
def linear_to_srgb_uint8(linear: np.ndarray) -> np.ndarray:
"""Map float32 linear-light [0, 1] back to uint8 sRGB (inverse EOTF)."""
return np.clip(linear_to_srgb_float(linear) + 0.5, 0, 255).astype(np.uint8)
+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",
tags=["fps"],
)
assert data["adapter_config"] == {"auth_token": "secret123"}
# The auth_token is a live shared secret and must NEVER be echoed back
# over the API — it is masked to "" in every response.
assert data["adapter_config"] == {"auth_token": ""}
assert data["description"] == "My game"
assert data["tags"] == ["fps"]
def test_update_with_blank_token_preserves_secret(self, client, game_store):
"""The API masks secrets, so the edit form re-submits a blank token for
an unchanged secret. The update must PRESERVE the stored secret rather
than overwrite it with the blank (otherwise a no-op edit wipes the key).
"""
created = _create_integration(client, adapter_config={"auth_token": "secret123"})
gi_id = created["id"]
resp = client.put(
f"/api/v1/game-integrations/{gi_id}",
json={"name": "Renamed", "adapter_config": {"auth_token": ""}},
)
assert resp.status_code == 200, resp.text
# The stored (decrypted) secret is unchanged despite the blank submit.
cfg = game_store.get_integration(gi_id)
assert cfg.adapter_config.get("auth_token") == "secret123"
def test_update_with_new_token_replaces_secret(self, client, game_store):
"""A non-empty token in the update is a deliberate change and is kept."""
created = _create_integration(client, adapter_config={"auth_token": "secret123"})
gi_id = created["id"]
resp = client.put(
f"/api/v1/game-integrations/{gi_id}",
json={"adapter_config": {"auth_token": "rotated456"}},
)
assert resp.status_code == 200, resp.text
assert game_store.get_integration(gi_id).adapter_config.get("auth_token") == "rotated456"
def test_create_duplicate_name(self, client):
_create_integration(client, name="Unique")
resp = client.post(
@@ -132,8 +132,18 @@ def test_prune_by_max_entries(tmp_db):
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
for _ in range(10):
repo.record(_make_entry())
# Give each entry a distinct, increasing timestamp and capture insertion
# order so we can assert *which* five survive — not just the count. The
# engine settings→prune path must keep the NEWEST five (keeping the wrong
# half of an audit log would otherwise pass a count-only assertion).
from ledgrab.storage.activity_log import ActivityLogFilters
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
ids = []
for i in range(10):
e = _make_entry(ts=base + timedelta(hours=i))
ids.append(e.id)
repo.record(e)
assert repo.count() == 10
@@ -141,6 +151,10 @@ def test_prune_by_max_entries(tmp_db):
engine._prune()
assert repo.count() == 5
remaining = {r.id for r in repo.query(ActivityLogFilters(), limit=20)}
# Newest five (highest seq / latest ts) survive; oldest five are pruned.
assert all(sid in remaining for sid in ids[5:])
assert all(sid not in remaining for sid in ids[:5])
def test_prune_disabled_is_noop(tmp_db):
@@ -467,13 +467,18 @@ def test_activity_logged_event_payload_shape():
def test_entry_id_format():
"""Entry IDs must be 'al_' followed by 8 hex characters."""
"""Entry IDs must be 'al_' followed by the full 32-hex uuid4.
Widened from 8 hex (32 bits) to the full 128-bit uuid4 so a collision on the
UNIQUE id column which the best-effort recorder would silently drop is
astronomically unlikely even against the full retention window.
"""
recorder, persisted, _ = _make_recorder()
recorder.record(category=ActivityCategory.SYSTEM, action="a", message="m")
entry_id = persisted[0].id
assert entry_id.startswith("al_"), f"id does not start with 'al_': {entry_id!r}"
suffix = entry_id[3:]
assert len(suffix) == 8, f"id suffix length is {len(suffix)}, expected 8: {entry_id!r}"
assert len(suffix) == 32, f"id suffix length is {len(suffix)}, expected 32: {entry_id!r}"
assert all(c in "0123456789abcdef" for c in suffix), f"id suffix is not hex: {suffix!r}"
+126 -1
View File
@@ -1,7 +1,7 @@
"""Tests for AutomationEngine — rule evaluation in isolation."""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -10,6 +10,7 @@ from ledgrab.storage.automation import (
ApplicationRule,
Automation,
DisplayStateRule,
ManualTriggerRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
@@ -552,6 +553,130 @@ class TestHTTPValueStreamExtraction:
assert _extract_simple_path(body, "MediaContainer.size", "") == 2
assert _extract_simple_path(body, "MediaContainer.Metadata[0].title", "") == "Show"
# ---------------------------------------------------------------------------
# Manual trigger — one-shot apply gated by the automation's rules
# ---------------------------------------------------------------------------
class TestManualTrigger:
"""fire_manual_trigger: evaluate rules with the manual term True, then
apply the scene once (without entering the sticky active state)."""
def _make(self, rules, *, enabled=True, logic="or", scene=None, aid="auto_manual"):
now = datetime.now(timezone.utc)
return Automation(
id=aid,
name="Manual",
enabled=enabled,
rule_logic=logic,
rules=rules,
scene_preset_id=scene,
deactivation_mode="none",
deactivation_scene_preset_id=None,
created_at=now,
updated_at=now,
)
def test_handle_manual_inert_by_default(self, engine):
# Outside a manual fire the rule reads False, so a manual-only
# automation never activates from the background tick.
assert engine._handle_manual(ManualTriggerRule(), None) is False
@pytest.mark.asyncio
async def test_fires_no_scene_one_shot(self, engine):
auto = self._make([ManualTriggerRule()])
status, errors = await engine.fire_manual_trigger(auto)
assert status == "triggered"
assert errors == []
# One-shot: records last_activated but does NOT enter the sticky state
# (so the background tick has nothing to reconcile away → no bounce).
assert auto.id not in engine._active_automations
assert auto.id in engine._last_activated
# The transient flag is always cleared after the evaluation.
assert engine._manual_fire_active is False
@pytest.mark.asyncio
async def test_skipped_when_and_companion_false(self, engine):
# manual AND an unset webhook → AND fails → nothing applied.
auto = self._make([ManualTriggerRule(), WebhookRule(token="nope")], logic="and")
status, errors = await engine.fire_manual_trigger(auto)
assert status == "skipped"
assert errors == []
assert auto.id not in engine._last_activated
@pytest.mark.asyncio
async def test_fires_or_when_companion_false(self, engine):
# manual OR an unset webhook → manual alone satisfies "or".
auto = self._make([ManualTriggerRule(), WebhookRule(token="nope")], logic="or")
status, _errors = await engine.fire_manual_trigger(auto)
assert status == "triggered"
@pytest.mark.asyncio
async def test_works_when_disabled(self, engine):
# enabled gates only the background loop; a manual trigger ignores it.
auto = self._make([ManualTriggerRule()], enabled=False)
status, _errors = await engine.fire_manual_trigger(auto)
assert status == "triggered"
@pytest.mark.asyncio
async def test_manual_only_automation_inert_in_background_tick(self, engine, mock_store):
created = mock_store.create_automation(
name="manual-bg",
enabled=True,
rule_logic="or",
rules=[ManualTriggerRule()],
scene_preset_id=None,
)
await engine.trigger_evaluate()
assert created.id not in engine._active_automations
@pytest.mark.asyncio
async def test_audits_with_user_actor(self, engine):
rec = MagicMock()
with patch("ledgrab.core.activity_log.recorder.get_module_recorder", return_value=rec):
await engine.fire_manual_trigger(self._make([ManualTriggerRule()]))
rec.record.assert_called_once()
kwargs = rec.record.call_args.kwargs
assert kwargs["action"] == "automation.triggered"
# No explicit actor → recorder resolves the current user (not "system").
assert "actor" not in kwargs
@pytest.mark.asyncio
async def test_applies_scene_activated_maps_to_triggered(self, engine):
engine._scene_preset_store = MagicMock()
preset = MagicMock()
preset.name = "Scene"
engine._scene_preset_store.get_preset.return_value = preset
engine._target_store = MagicMock()
engine._device_store = MagicMock()
auto = self._make([ManualTriggerRule()], scene="scene_x")
with patch(
"ledgrab.core.scenes.scene_activator.apply_scene_state",
new=AsyncMock(return_value=("activated", [])),
) as apply_mock:
status, errors = await engine.fire_manual_trigger(auto)
apply_mock.assert_awaited_once()
assert status == "triggered"
assert errors == []
@pytest.mark.asyncio
async def test_applies_scene_partial_passthrough(self, engine):
engine._scene_preset_store = MagicMock()
preset = MagicMock()
preset.name = "Scene"
engine._scene_preset_store.get_preset.return_value = preset
engine._target_store = MagicMock()
engine._device_store = MagicMock()
auto = self._make([ManualTriggerRule()], scene="scene_x")
with patch(
"ledgrab.core.scenes.scene_activator.apply_scene_state",
new=AsyncMock(return_value=("partial", ["dev1: timeout"])),
):
status, errors = await engine.fire_manual_trigger(auto)
assert status == "partial"
assert errors == ["dev1: timeout"]
def test_chained_indices(self):
from ledgrab.core.processing.value_stream import _extract_simple_path
@@ -21,6 +21,7 @@ from ledgrab.storage.automation import (
HTTPPollRule,
MQTTRule,
Rule,
SolarRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
@@ -31,6 +32,7 @@ EXPECTED_RULE_TYPES = {
StartupRule,
ApplicationRule,
TimeOfDayRule,
SolarRule,
SystemIdleRule,
DisplayStateRule,
MQTTRule,
@@ -13,6 +13,7 @@ Coverage targets
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, patch
import pytest
@@ -233,7 +234,7 @@ class TestAuthInstrumentation:
req = self._make_mock_request()
with pytest.raises(Exception): # HTTPException 401
verify_api_key(req, None)
asyncio.run(verify_api_key(req, None))
# At least one warning record about auth
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
@@ -251,7 +252,7 @@ class TestAuthInstrumentation:
req = self._make_mock_request(client_ip="127.0.0.1")
with pytest.raises(Exception): # HTTPException 401
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
# At least one warning-level auth record
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
@@ -286,7 +287,7 @@ class TestAuthInstrumentation:
req = self._make_mock_request(client_ip="192.168.1.100")
with pytest.raises(Exception): # HTTPException 401
verify_api_key(req, None)
asyncio.run(verify_api_key(req, None))
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
@@ -302,7 +303,7 @@ class TestAuthInstrumentation:
req = self._make_mock_request(client_ip="10.0.0.5")
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
auth_records = [e for e in persisted if e.category == ActivityCategory.AUTH]
assert len(auth_records) >= 1
@@ -7,6 +7,7 @@ shape, and self-referential exclusion.
from __future__ import annotations
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
@@ -95,7 +96,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
assert len(persisted) >= 1, "Expected at least one auth record"
for entry in persisted:
@@ -121,7 +122,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, None)
asyncio.run(verify_api_key(req, None))
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
@@ -152,7 +153,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
assert len(warnings) >= 1
@@ -284,7 +285,7 @@ class TestNoSecretLeakage:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
for entry in persisted:
for v in entry.metadata.values():
@@ -367,7 +368,7 @@ class TestBestEffortResilience:
# Should raise the HTTP 401, not the recorder RuntimeError
with pytest.raises(Exception) as exc_info:
verify_api_key(req, None) # missing creds
asyncio.run(verify_api_key(req, None)) # missing creds
# Must be an HTTPException (401), NOT the RuntimeError from the recorder
assert "RuntimeError" not in type(exc_info.value).__name__
@@ -734,7 +735,7 @@ class TestNoDuplicateRecords:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
rejected = [e for e in persisted if e.action == "auth.rejected"]
assert len(rejected) == 1, f"Expected exactly 1 auth.rejected record, got {len(rejected)}"
@@ -768,7 +769,7 @@ class TestMetadataShape:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
rejected = [e for e in persisted if e.action == "auth.rejected"]
assert len(rejected) >= 1
@@ -800,7 +801,7 @@ class TestMetadataShape:
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
rejected = [e for e in persisted if e.action == "auth.rejected"]
assert rejected[0].metadata["client"] == _EXPECTED_IP
@@ -1041,7 +1042,7 @@ class TestCategorySeverityContract:
cfg.auth.api_keys = {"dev": "good"}
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
self._check_all_entries(persisted)
auth_records = [e for e in persisted if e.category == "auth"]
@@ -1384,7 +1385,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "correct-key"}
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
return persisted_ref
@@ -1414,7 +1415,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "real-key"}
mock_cfg.return_value = cfg
try:
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
except Exception:
exceptions_raised += 1
@@ -1444,7 +1445,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "right"}
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
rejected = [e for e in all_persisted if e.action == "auth.rejected"]
assert len(rejected) == len(
@@ -1477,7 +1478,7 @@ class TestAuthFailureThrottle:
cfg.auth.api_keys = {"dev": "correct"}
mock_cfg.return_value = cfg
with pytest.raises(Exception):
verify_api_key(req, creds)
asyncio.run(verify_api_key(req, creds))
rejected = [e for e in all_persisted if e.action == "auth.rejected"]
assert (
+40
View File
@@ -131,3 +131,43 @@ def test_build_frame_total_length(led_count):
frame = client._build_frame(pixels, brightness=255)
assert len(frame) == 6 + led_count * 3
async def test_close_settles_before_port_close(monkeypatch):
"""close() must let the board paint the black frame before resetting it.
The black frame has to be written AND given settle time before
``serial.close()`` toggles DTR (Arduino auto-reset). If the reset wins the
race the strip latches its last lit frame and "stays on". This guards the
ordering: write flush sleep(settle) close.
"""
import concurrent.futures
from unittest.mock import MagicMock
from ledgrab.core.devices import adalight_client as mod
client = _make_client(led_count=3)
events: list[str] = []
serial = MagicMock()
serial.is_open = True
serial.write.side_effect = lambda *_a, **_k: events.append("write")
serial.flush.side_effect = lambda *_a, **_k: events.append("flush")
serial.close.side_effect = lambda *_a, **_k: events.append("close")
client._serial = serial
client._connected = True
client._tx_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
async def fake_sleep(_seconds):
events.append(f"sleep:{_seconds}")
monkeypatch.setattr(mod.asyncio, "sleep", fake_sleep)
await client.close()
# Black frame is written and flushed, the board is given settle time, and
# ONLY THEN is the port closed (which resets the board).
assert events == ["write", "flush", f"sleep:{mod.BLACK_FRAME_SETTLE_DELAY}", "close"]
assert mod.BLACK_FRAME_SETTLE_DELAY > 0
+113
View File
@@ -0,0 +1,113 @@
"""Tests for spatio-temporal dithering of the final 8-bit quantization.
The load-bearing property is *temporal convergence*: over many frames, the
dithered output time-averages back to the true sub-integer value (that's what
lets the eye see a higher effective bit depth instead of banding).
"""
from __future__ import annotations
import numpy as np
from ledgrab.core.capture.calibration import calibration_from_dict, calibration_to_dict
from ledgrab.core.capture.edge_interpolation import average_edge_to_leds
from ledgrab.utils.dither import ordered_dither_quantize
class TestOrderedDither:
def test_single_frame_is_floor_or_ceil(self):
vals = np.array([[100.4, 50.0, 200.7]], dtype=np.float32)
out = ordered_dither_quantize(vals, frame_index=7)
assert out.dtype == np.uint8
assert out[0, 0] in (100, 101)
assert out[0, 1] == 50 # exact integer never moves
assert out[0, 2] in (200, 201)
def test_integers_are_stable_across_frames(self):
vals = np.array([[10.0, 20.0, 30.0]], dtype=np.float32)
for f in range(20):
out = ordered_dither_quantize(vals, frame_index=f)
assert list(out[0]) == [10, 20, 30]
def test_temporal_average_converges_to_true_value(self):
vals = np.array([[100.4, 50.9, 200.25]], dtype=np.float32)
acc = np.zeros(3, dtype=np.float64)
n = 2000
for f in range(n):
acc += ordered_dither_quantize(vals, frame_index=f)[0]
avg = acc / n
assert abs(avg[0] - 100.4) < 0.3
assert abs(avg[1] - 50.9) < 0.3
assert abs(avg[2] - 200.25) < 0.3
def test_same_threshold_across_channels_preserves_hue_steps(self):
# All three channels share the per-LED threshold, so an equal-channel
# grey never splits into a coloured pixel.
vals = np.array([[123.5, 123.5, 123.5]], dtype=np.float32)
for f in range(50):
out = ordered_dither_quantize(vals, frame_index=f)
assert out[0, 0] == out[0, 1] == out[0, 2]
def test_clips_to_byte_range(self):
vals = np.array([[-5.0, 255.9, 300.0]], dtype=np.float32)
out = ordered_dither_quantize(vals, frame_index=3)
assert out[0, 0] == 0
assert out[0, 1] == 255
assert out[0, 2] == 255
class TestEdgeReductionDither:
def _half_edge(self):
# 2 columns (black, white) → 1 LED whose mean is exactly 127.5, i.e. a
# value the plain uint8 path can only represent as 127.
e = np.zeros((2, 2, 3), dtype=np.uint8)
e[:, 1, :] = 255
return e
def test_dithered_output_varies_by_frame(self):
edge = self._half_edge()
seen = {
int(average_edge_to_leds(edge, "top", 1, {}, "k", dither=True, frame_index=f)[0, 0])
for f in range(50)
}
# The 127.5 LED straddles two codes → dither flips it across frames.
assert seen == {127, 128}
def test_temporal_average_recovers_the_half_step(self):
edge = self._half_edge()
# Plain quantization truncates 127.5 → 127; dither recovers ~127.5.
plain = int(average_edge_to_leds(edge, "top", 1, {}, "k")[0, 0])
acc = np.zeros(3, dtype=np.float64)
n = 1500
for f in range(n):
acc += average_edge_to_leds(edge, "top", 1, {}, "k", dither=True, frame_index=f)[0]
avg = acc[0] / n
assert plain == 127
assert abs(avg - 127.5) < 0.2
class TestCalibrationRoundTrip:
def test_dither_round_trips(self):
cfg = calibration_from_dict(
{
"mode": "simple",
"layout": "clockwise",
"start_position": "bottom_left",
"leds_top": 5,
"dither": True,
}
)
assert cfg.dither is True
assert calibration_to_dict(cfg).get("dither") is True
def test_dither_default_off_and_omitted(self):
cfg = calibration_from_dict(
{
"mode": "simple",
"layout": "clockwise",
"start_position": "bottom_left",
"leds_top": 5,
}
)
assert cfg.dither is False
assert "dither" not in calibration_to_dict(cfg)
+92
View File
@@ -0,0 +1,92 @@
"""Tests for linear-light blending in the per-LED reduction.
Verifies the sRGBlinear conversion correctness and that the edge-reduction
kernel, when ``linear=True``, blends in linear light (a mid-grey from black +
white is brighter than the gamma-space mean) plus the CalibrationConfig
round-trip of the opt-in flag.
"""
from __future__ import annotations
import numpy as np
from ledgrab.core.capture.calibration import calibration_from_dict, calibration_to_dict
from ledgrab.core.capture.edge_interpolation import average_edge_to_leds
from ledgrab.utils.linear_light import (
SRGB_TO_LINEAR_LUT,
linear_to_srgb_uint8,
srgb_to_linear,
)
class TestConversions:
def test_lut_endpoints_and_monotonic(self):
assert SRGB_TO_LINEAR_LUT.shape == (256,)
assert SRGB_TO_LINEAR_LUT[0] == 0.0
assert abs(SRGB_TO_LINEAR_LUT[255] - 1.0) < 1e-6
assert np.all(np.diff(SRGB_TO_LINEAR_LUT) > 0) # strictly increasing
def test_mid_grey_decodes_below_half(self):
# sRGB 0.5 (≈128) is ~0.214 in linear light, not 0.5.
assert 0.18 < float(SRGB_TO_LINEAR_LUT[128]) < 0.25
def test_round_trip_is_near_identity(self):
ramp = np.arange(256, dtype=np.uint8)
back = linear_to_srgb_uint8(srgb_to_linear(ramp))
assert np.max(np.abs(back.astype(int) - ramp.astype(int))) <= 1
def test_linear_mean_of_black_and_white_is_brighter(self):
lin = (srgb_to_linear(np.array([0, 255], dtype=np.uint8))).mean()
encoded = int(linear_to_srgb_uint8(np.array([lin], dtype=np.float32))[0])
assert encoded > 127 # brighter than the sRGB mean (127)
assert 180 < encoded < 195 # ~188
class TestEdgeReductionLinear:
def _edge(self):
# top edge (axis=0): shape (rows=2, width=4, 3); two black + two white cols
e = np.zeros((2, 4, 3), dtype=np.uint8)
e[:, 2:, :] = 255
return e
def test_srgb_blend_is_plain_mean(self):
out = average_edge_to_leds(self._edge(), "top", 1, {}, "k", linear=False)
assert int(out[0, 0]) == 127
def test_linear_blend_is_brighter(self):
out = average_edge_to_leds(self._edge(), "top", 1, {}, "k", linear=True)
assert int(out[0, 0]) > 127
assert 180 < int(out[0, 0]) < 195
def test_uniform_edge_unchanged_by_linear(self):
e = np.full((2, 4, 3), 200, dtype=np.uint8)
out = average_edge_to_leds(e, "top", 1, {}, "k", linear=True)
# A flat colour survives the decode→mean→encode round-trip (±1).
assert abs(int(out[0, 0]) - 200) <= 1
class TestCalibrationRoundTrip:
def test_linear_blend_round_trips_simple(self):
cfg = calibration_from_dict(
{
"mode": "simple",
"layout": "clockwise",
"start_position": "bottom_left",
"leds_top": 5,
"linear_blend": True,
}
)
assert cfg.linear_blend is True
assert calibration_to_dict(cfg).get("linear_blend") is True
def test_default_is_off_and_omitted(self):
cfg = calibration_from_dict(
{
"mode": "simple",
"layout": "clockwise",
"start_position": "bottom_left",
"leds_top": 5,
}
)
assert cfg.linear_blend is False
assert "linear_blend" not in calibration_to_dict(cfg)
+178
View File
@@ -0,0 +1,178 @@
"""Tests for the LoL poll manager + shared game-integration payload processing.
Covers the runtime wiring that was previously missing: the orphaned
``LoLPoller`` is now driven by ``LoLPollManager``, and polled payloads flow
through the same ``process_payload`` core the HTTP ingest route uses.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
from ledgrab.core.game_integration import runtime_state
from ledgrab.core.game_integration.adapters.lol_adapter import LoLAdapter
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
# ── Fakes ───────────────────────────────────────────────────────────────
class _FakeBus:
def __init__(self) -> None:
self.published: list[Any] = []
def publish(self, event: Any) -> None:
self.published.append(event)
@dataclass
class _FakeConfig:
id: str
enabled: bool
adapter_type: str
adapter_config: dict = field(default_factory=dict)
class _FakePoller:
"""Stand-in for LoLPoller so the manager test never spawns real threads."""
instances: list["_FakePoller"] = []
def __init__(self, adapter_config: dict, callback: Any) -> None:
self.adapter_config = adapter_config
self.callback = callback
self.started = False
self.stopped = False
_FakePoller.instances.append(self)
def start(self) -> None:
self.started = True
def stop(self) -> None:
self.stopped = True
@pytest.fixture
def fake_poller(monkeypatch):
_FakePoller.instances = []
monkeypatch.setattr("ledgrab.core.game_integration.lol_poll_manager.LoLPoller", _FakePoller)
return _FakePoller
def _lol(id: str = "lol1", enabled: bool = True, **cfg) -> _FakeConfig:
return _FakeConfig(id=id, enabled=enabled, adapter_type="lol", adapter_config=cfg)
# ── process_payload ─────────────────────────────────────────────────────
class TestProcessPayload:
def setup_method(self):
runtime_state.cleanup_state("pp_test")
def test_publishes_parsed_events_and_records_stats(self):
bus = _FakeBus()
payload = {
"activePlayer": {
"championStats": {"currentHealth": 50, "maxHealth": 100},
"summonerName": "Me",
"level": 9,
},
"allPlayers": [],
}
events = runtime_state.process_payload("pp_test", LoLAdapter, {}, payload, bus)
assert len(events) >= 1
assert any(e.event_type == "health" and abs(e.value - 0.5) < 1e-6 for e in events)
assert len(bus.published) == len(events)
assert runtime_state.get_stats("pp_test")["event_count"] == len(events)
def test_swallows_parse_errors(self):
bus = _FakeBus()
class _Boom:
ADAPTER_TYPE = "boom"
@classmethod
def parse_payload(cls, *a):
raise ValueError("bad frame")
events = runtime_state.process_payload("pp_test", _Boom, {}, {}, bus)
assert events == []
assert bus.published == []
# ── LoLPollManager lifecycle ────────────────────────────────────────────
class TestLoLPollManager:
def test_sync_starts_poller_for_enabled_lol(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync([_lol()])
assert mgr.active_count == 1
assert fake_poller.instances[-1].started is True
def test_sync_ignores_non_lol_and_disabled(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync(
[
_FakeConfig("cs", True, "cs2", {}),
_lol("off", enabled=False),
]
)
assert mgr.active_count == 0
assert fake_poller.instances == []
def test_sync_is_idempotent_for_unchanged_config(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync([_lol(poll_interval_ms=500)])
first = fake_poller.instances[-1]
mgr.sync([_lol(poll_interval_ms=500)])
assert mgr.active_count == 1
assert len(fake_poller.instances) == 1 # not restarted
assert first.stopped is False
def test_sync_restarts_on_config_change(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync([_lol(poll_interval_ms=500)])
first = fake_poller.instances[-1]
mgr.sync([_lol(poll_interval_ms=1000)])
assert first.stopped is True
assert len(fake_poller.instances) == 2
assert mgr.active_count == 1
def test_sync_stops_when_removed_or_disabled(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync([_lol()])
poller = fake_poller.instances[-1]
mgr.sync([]) # integration gone
assert poller.stopped is True
assert mgr.active_count == 0
def test_stop_all(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync([_lol("a"), _lol("b")])
assert mgr.active_count == 2
mgr.stop_all()
assert mgr.active_count == 0
assert all(p.stopped for p in fake_poller.instances)
def test_callback_routes_polled_data_through_process_payload(self, fake_poller):
bus = _FakeBus()
mgr = LoLPollManager(bus)
mgr.sync([_lol("cbtest")])
callback = fake_poller.instances[-1].callback
callback(
{
"activePlayer": {
"championStats": {"currentHealth": 80, "maxHealth": 100},
"summonerName": "Me",
"level": 1,
},
"allPlayers": [],
}
)
assert any(e.event_type == "health" for e in bus.published)
+89
View File
@@ -0,0 +1,89 @@
"""Tests for Nanoleaf extControl v2 per-panel streaming.
The device-side (panelLayout fetch, extControl enable, UDP send) needs a real
controller to validate; here we lock down the parts that DON'T: panel
ordering, strippanel resampling, the exact UDP packet framing, and the
``nanoleaf_per_panel`` config round-trip through the device store.
"""
from __future__ import annotations
import struct
from ledgrab.core.devices.nanoleaf_client import (
build_extcontrol_v2_packet,
map_pixels_to_panels,
order_panels,
)
from ledgrab.storage.device_store import Device
class TestOrderPanels:
def test_sorts_by_x_then_y_and_drops_controller(self):
position = [
{"panelId": 5, "x": 100, "y": 0},
{"panelId": 3, "x": 0, "y": 0},
{"panelId": 0, "x": 50, "y": 50}, # controller / rhythm → dropped
{"panelId": 7, "x": 0, "y": 100},
]
assert order_panels(position) == [3, 7, 5]
def test_ignores_non_integer_panel_ids(self):
assert order_panels([{"panelId": "x", "x": 0, "y": 0}, {"x": 1, "y": 1}]) == []
class TestMapPixelsToPanels:
def test_nearest_neighbour_resample(self):
pixels = [[10, 10, 10], [20, 20, 20], [30, 30, 30], [40, 40, 40]]
out = map_pixels_to_panels(pixels, [1, 2])
assert out == [(1, 10, 10, 10), (2, 30, 30, 30)]
def test_empty_strip_is_black(self):
assert map_pixels_to_panels([], [9, 8]) == [(9, 0, 0, 0), (8, 0, 0, 0)]
def test_more_panels_than_pixels_repeats(self):
out = map_pixels_to_panels([[255, 0, 0]], [1, 2, 3])
assert out == [(1, 255, 0, 0), (2, 255, 0, 0), (3, 255, 0, 0)]
class TestPacket:
def test_framing_is_byte_exact(self):
panels = [(100, 10, 20, 30), (200, 40, 50, 60)]
pkt = build_extcontrol_v2_packet(panels)
assert len(pkt) == 2 + 2 * 8 # uint16 header + 8 bytes/panel
assert struct.unpack(">H", pkt[0:2])[0] == 2
pid, r, g, b, w, trans = struct.unpack(">HBBBBH", pkt[2:10])
assert (pid, r, g, b, w, trans) == (100, 10, 20, 30, 0, 1)
pid2, r2, g2, b2, w2, trans2 = struct.unpack(">HBBBBH", pkt[10:18])
assert (pid2, r2, g2, b2, w2, trans2) == (200, 40, 50, 60, 0, 1)
def test_values_are_masked_to_byte_range(self):
pkt = build_extcontrol_v2_packet([(70000, 300, -5, 256)])
pid, r, g, b, w, trans = struct.unpack(">HBBBBH", pkt[2:10])
assert pid == 70000 & 0xFFFF
assert r == 300 & 0xFF and b == 256 & 0xFF
class TestConfigRoundTrip:
def _device(self, per_panel: bool) -> Device:
return Device(
device_id="d1",
name="Shapes",
url="nanoleaf://1.2.3.4",
led_count=10,
device_type="nanoleaf",
nanoleaf_token="tok",
nanoleaf_per_panel=per_panel,
)
def test_per_panel_round_trips_through_store(self):
d = self._device(True)
assert d.to_dict().get("nanoleaf_per_panel") is True
back = Device.from_dict(d.to_dict())
assert back.nanoleaf_per_panel is True
assert back.to_config().nanoleaf_per_panel is True
def test_default_off_is_omitted(self):
d = self._device(False)
assert "nanoleaf_per_panel" not in d.to_dict()
assert d.to_config().nanoleaf_per_panel is False
+181
View File
@@ -0,0 +1,181 @@
"""Tests for audio-reactive palette modulation on procedural effects.
Covers the model round-trip, the AudioEnergyTap (resolve/acquire/energy via
fakes), and the per-frame brightness/saturation modulation applied to a
rendered effect frame.
"""
from __future__ import annotations
from datetime import datetime, timezone
import numpy as np
from ledgrab.core.processing.audio_energy_tap import AudioEnergyTap
from ledgrab.core.processing.effect_stream import EffectColorStripStream
from ledgrab.storage.color_strip_source import EffectColorStripSource
def _make_source(**overrides) -> EffectColorStripSource:
base = dict(
id="fx1",
name="fx",
source_type="effect",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
effect_type="plasma",
)
base.update(overrides)
return EffectColorStripSource.create_from_kwargs(**base)
# ── Model ───────────────────────────────────────────────────────────────
class TestModel:
def test_defaults(self):
s = _make_source()
assert s.audio_reactive is False
assert s.reactive_mode == "brightness"
assert s.reactive_audio_source_id == ""
def test_round_trip_preserves_reactive_fields(self):
s = _make_source(
audio_reactive=True,
reactive_audio_source_id="as_42",
reactive_mode="both",
reactive_intensity=0.5,
)
back = EffectColorStripSource.from_dict(s.to_dict())
assert back.audio_reactive is True
assert back.reactive_audio_source_id == "as_42"
assert back.reactive_mode == "both"
assert back.reactive_intensity.value == 0.5
def test_apply_update_changes_reactive_fields(self):
s = _make_source()
s.apply_update(audio_reactive=True, reactive_mode="saturation", reactive_intensity=0.9)
assert s.audio_reactive is True
assert s.reactive_mode == "saturation"
assert s.reactive_intensity.value == 0.9
# ── AudioEnergyTap ──────────────────────────────────────────────────────
class _Analysis:
def __init__(self, rms):
self.rms = rms
class _CaptureStream:
def __init__(self, rms=0.25):
self._rms = rms
def get_latest_analysis(self):
return _Analysis(self._rms)
class _Resolved:
device_index = 3
is_loopback = True
audio_template_id = ""
class _SourceStore:
def resolve_audio_source(self, sid):
return _Resolved()
class _Manager:
def __init__(self):
self.acquired = []
self.released = []
self.stream = _CaptureStream()
def acquire(self, device, loopback, engine_type=None, engine_config=None):
self.acquired.append((device, loopback))
return self.stream
def release(self, device, loopback, engine_type=None):
self.released.append((device, loopback))
class TestAudioEnergyTap:
def test_unavailable_without_manager(self):
tap = AudioEnergyTap(None)
assert tap.available is False
tap.start()
assert tap.energy() == 0.0
def test_configure_resolves_capture_params(self):
mgr = _Manager()
tap = AudioEnergyTap(mgr, _SourceStore())
tap.configure("as_1")
tap.start()
assert tap.active is True
assert mgr.acquired == [(3, True)]
def test_energy_smooths_rms(self):
mgr = _Manager()
tap = AudioEnergyTap(mgr, _SourceStore())
tap.configure("as_1")
tap.start()
# rms 0.25 * gain 4 = 1.0 (clamped); EMA rises toward 1.0
e1 = tap.energy()
e2 = tap.energy()
assert 0.0 < e1 < e2 <= 1.0
def test_stop_releases_and_resets(self):
mgr = _Manager()
tap = AudioEnergyTap(mgr, _SourceStore())
tap.configure("as_1")
tap.start()
tap.stop()
assert tap.active is False
assert mgr.released == [(3, True)]
assert tap.energy() == 0.0
# ── Modulation ──────────────────────────────────────────────────────────
class _FixedTap:
def __init__(self, energy):
self._e = energy
def energy(self, smoothing=0.4):
return self._e
class TestModulation:
def _stream(self, mode, intensity, energy):
src = _make_source(audio_reactive=True, reactive_mode=mode, reactive_intensity=intensity)
st = EffectColorStripStream(src)
st._audio_tap = _FixedTap(energy)
return st
def test_brightness_silence_dims_to_zero(self):
st = self._stream("brightness", 1.0, 0.0)
buf = np.array([[200, 100, 50]], dtype=np.uint8)
st._apply_audio_modulation(buf)
assert np.array_equal(buf, np.array([[0, 0, 0]], dtype=np.uint8))
def test_brightness_full_energy_preserves(self):
st = self._stream("brightness", 1.0, 1.0)
buf = np.array([[200, 100, 50]], dtype=np.uint8)
st._apply_audio_modulation(buf)
assert np.array_equal(buf, np.array([[200, 100, 50]], dtype=np.uint8))
def test_saturation_silence_desaturates_to_luminance(self):
st = self._stream("saturation", 1.0, 0.0)
buf = np.array([[200, 100, 50]], dtype=np.uint8)
st._apply_audio_modulation(buf)
# All channels collapse to the luminance value (greyscale).
assert buf[0, 0] == buf[0, 1] == buf[0, 2]
def test_zero_intensity_is_noop(self):
st = self._stream("both", 0.0, 0.0)
buf = np.array([[200, 100, 50]], dtype=np.uint8)
st._apply_audio_modulation(buf)
assert np.array_equal(buf, np.array([[200, 100, 50]], dtype=np.uint8))
+334
View File
@@ -0,0 +1,334 @@
"""Regression tests for the 2026-06-18 production-readiness review fixes.
Each test maps to a confirmed finding from the review and would fail against
the pre-fix code. Grouped by area; see the inline finding tags (#N).
"""
from __future__ import annotations
import threading
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
from ledgrab.storage.activity_log import (
ActivityCategory,
ActivityLogEntry,
ActivityLogFilters,
ActivitySeverity,
)
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.storage.database import Database
def _entry(*, ts: datetime | None = None, message: str = "m") -> ActivityLogEntry:
return ActivityLogEntry(
id="al_" + uuid.uuid4().hex[:8],
ts=ts or datetime.now(timezone.utc),
category=ActivityCategory.SYSTEM,
action="test.action",
severity=ActivitySeverity.INFO,
actor="system",
message=message,
)
@pytest.fixture
def repo(tmp_db: Database) -> ActivityLogRepository:
return ActivityLogRepository(tmp_db)
# ---------------------------------------------------------------------------
# #8 — activity-log entry id has full 128-bit entropy
# ---------------------------------------------------------------------------
def test_new_id_uses_full_uuid_hex():
from ledgrab.core.activity_log.recorder import _new_id
val = _new_id()
assert val.startswith("al_")
hex_part = val[3:]
assert len(hex_part) == 32 # full uuid4 hex (was 8 → collision-prone)
int(hex_part, 16) # parses as hex
# ---------------------------------------------------------------------------
# #26 — sanitize_display honours its bounded-length contract for tiny maxlen
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("maxlen", [0, 1, 2, 5])
def test_sanitize_display_respects_bound(maxlen):
from ledgrab.core.activity_log.sanitize import sanitize_display
result = sanitize_display("abcdef", maxlen=maxlen)
assert len(result) <= maxlen
def test_sanitize_display_degenerate_maxlen_returns_empty():
from ledgrab.core.activity_log.sanitize import sanitize_display
assert sanitize_display("abcdef", maxlen=0) == ""
assert sanitize_display("abcdef", maxlen=-3) == ""
# ---------------------------------------------------------------------------
# #10 — non-ASCII Bearer / WS token does not raise from compare_digest
# ---------------------------------------------------------------------------
def test_match_api_key_non_ascii_token_returns_none_not_raises():
from ledgrab.api.auth import _match_api_key
with patch("ledgrab.api.auth.get_config") as cfg:
c = MagicMock()
c.auth.api_keys = {"dev": "correct-key"}
cfg.return_value = c
# café contains a non-ASCII char; must cleanly fail to match, not raise.
assert _match_api_key("café") is None
assert _match_api_key("correct-key") == "dev"
# ---------------------------------------------------------------------------
# #15 — game adapters tolerate non-ASCII attacker-controlled tokens
# ---------------------------------------------------------------------------
def test_generic_webhook_adapter_non_ascii_header_returns_false():
from ledgrab.core.game_integration.adapters.generic_webhook_adapter import (
GenericWebhookAdapter,
)
ok = GenericWebhookAdapter.validate_auth(
{"Authorization": "Bearer café"}, {}, {"auth_token": "secret123"}
)
assert ok is False # no TypeError
def test_cs2_adapter_non_ascii_payload_token_returns_false():
from ledgrab.core.game_integration.adapters.cs2_adapter import CS2Adapter
ok = CS2Adapter.validate_auth({}, {"auth": {"token": "café"}}, {"auth_token": "secret123"})
assert ok is False # no TypeError
# ---------------------------------------------------------------------------
# #2 — async auth dependency sets the actor ContextVar visibly to the handler
# ---------------------------------------------------------------------------
def test_async_auth_dep_actor_visible_to_handler():
import asyncio
from ledgrab.api.auth import verify_api_key
# Guard against a regression back to a sync dependency (which would run the
# contextvar mutation in a throwaway threadpool context the handler can't see).
assert asyncio.iscoroutinefunction(verify_api_key)
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from ledgrab.core.activity_log.context import current_actor
app = FastAPI()
# Use Depends() as a default value rather than the ``AuthRequired`` Annotated
# alias: this module has ``from __future__ import annotations`` (stringized
# annotations), and the route is defined in a local scope FastAPI can't
# resolve the alias from — the default-value form sidesteps that entirely.
@app.get("/whoami")
async def whoami(auth=Depends(verify_api_key)):
return {"label": auth, "actor": current_actor.get()}
with patch("ledgrab.api.auth.get_config") as cfg:
c = MagicMock()
c.auth.api_keys = {"dev": "k"}
cfg.return_value = c
client = TestClient(app)
resp = client.get("/whoami", headers={"Authorization": "Bearer k"})
assert resp.status_code == 200
body = resp.json()
assert body["label"] == "dev"
# Pre-fix (sync dep) this would be "system"; the async dep makes it visible.
assert body["actor"] == "dev"
# ---------------------------------------------------------------------------
# #9 / #12 — auth-failure throttle dict survives concurrent access
# ---------------------------------------------------------------------------
def test_should_record_auth_failure_concurrent_no_exception():
from ledgrab.api import auth as auth_mod
auth_mod._auth_record_last.clear()
# Pre-fill to one below the hard cap so the eviction branch is hot.
cap = auth_mod._AUTH_THROTTLE_HARD_CAP
for i in range(cap - 1):
auth_mod._auth_record_last[f"seed-{i}"] = 0.0
errors: list[BaseException] = []
def hammer(base: int):
try:
for j in range(500):
auth_mod._should_record_auth_failure(f"{base}.{j}")
except BaseException as exc: # noqa: BLE001 - capture any escape
errors.append(exc)
threads = [threading.Thread(target=hammer, args=(t,)) for t in range(16)]
for th in threads:
th.start()
for th in threads:
th.join(timeout=30)
assert not errors, f"throttle raised under concurrency: {errors[:3]}"
assert len(auth_mod._auth_record_last) <= cap
auth_mod._auth_record_last.clear()
# ---------------------------------------------------------------------------
# #1 / #4 — since/until filter normalises naive + non-UTC-offset datetimes
# ---------------------------------------------------------------------------
def test_until_boundary_includes_entry_at_exact_instant(repo: ActivityLogRepository):
# Entry stored at exactly 12:00 UTC.
instant = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc)
repo.record(_entry(ts=instant, message="boundary"))
# A naive datetime-local value at the same wall-clock (no offset) — the
# realistic frontend path. Pre-fix this lexically excluded the row.
naive_until = datetime(2026, 6, 18, 12, 0, 0) # noqa: DTZ001 - intentional naive
page = repo.query(ActivityLogFilters(until=naive_until), limit=10)
assert len(page) == 1
def test_since_with_non_utc_offset_includes_correct_instant(repo: ActivityLogRepository):
# Stored at 13:30 UTC.
repo.record(_entry(ts=datetime(2026, 6, 18, 13, 30, tzinfo=timezone.utc), message="x"))
# since expressed in +02:00 == 13:00 UTC → row (13:30Z) must be included.
since_incl = datetime(2026, 6, 18, 15, 0, tzinfo=timezone(timedelta(hours=2)))
assert len(repo.query(ActivityLogFilters(since=since_incl), limit=10)) == 1
# since == 16:00+02:00 == 14:00 UTC → row (13:30Z) must be excluded.
since_excl = datetime(2026, 6, 18, 16, 0, tzinfo=timezone(timedelta(hours=2)))
assert len(repo.query(ActivityLogFilters(since=since_excl), limit=10)) == 0
# ---------------------------------------------------------------------------
# #21 — iter_export advances the keyset cursor correctly across batches
# ---------------------------------------------------------------------------
def test_iter_export_multi_batch_no_gaps_or_dupes(repo: ActivityLogRepository):
n = 7
for i in range(n):
repo.record(_entry(message=f"e{i}"))
# batch_size=2 over 7 rows → 4 batches, exercising cursor advancement.
exported = list(repo.iter_export(batch_size=2))
ids = [e.id for e in exported]
assert len(ids) == n
assert len(set(ids)) == n # no duplicates across batch boundaries
# Identical set to a single-batch run.
assert set(ids) == {e.id for e in repo.iter_export(batch_size=1000)}
# ---------------------------------------------------------------------------
# #13 — undecryptable secret envelope is preserved, not discarded
# ---------------------------------------------------------------------------
def test_decrypt_failure_preserves_envelope(monkeypatch):
from ledgrab.storage import game_integration as gi
envelope = "ENC:v1:undecryptable-blob"
monkeypatch.setattr(gi.secret_box, "is_encrypted", lambda v: v == envelope)
def _boom(_v):
raise ValueError("secret key missing")
monkeypatch.setattr(gi.secret_box, "decrypt", _boom)
result = gi._decrypt_adapter_config({"auth_token": envelope, "other": "x"})
# Pre-fix: result["auth_token"] == "" (data loss on the next write-through).
assert result["auth_token"] == envelope
assert result["other"] == "x"
# ---------------------------------------------------------------------------
# #22 — real-thread / real-DB concurrency on the repository
# ---------------------------------------------------------------------------
def test_repo_concurrent_writes_are_consistent(repo: ActivityLogRepository):
threads_n, per_thread = 8, 100
total = threads_n * per_thread
def worker():
for _ in range(per_thread):
repo.record(_entry())
threads = [threading.Thread(target=worker) for _ in range(threads_n)]
for th in threads:
th.start()
for th in threads:
th.join(timeout=60)
assert repo.count() == total
exported = list(repo.iter_export(batch_size=50))
assert len(exported) == total
assert len({e.id for e in exported}) == total # unique, no corruption
# ---------------------------------------------------------------------------
# #23 — CSV export strips control chars from string cells (defense-in-depth)
# ---------------------------------------------------------------------------
def test_csv_export_strips_control_chars(repo: ActivityLogRepository):
import csv
import io
from ledgrab.api.routes.activity_log import _CSV_COLUMNS, _export_csv_generator
evil = "evil\x00dev\x1b[31mred\x1b[0m\r\ninject"
repo.record(_entry(message=evil))
text = b"".join(_export_csv_generator(repo, ActivityLogFilters())).decode("utf-8")
rows = list(csv.reader(io.StringIO(text)))
assert len(rows) == 2 # header + 1 data row (newline did not split the field)
msg_cell = rows[1][_CSV_COLUMNS.index("message")]
for bad in ("\x00", "\x1b", "\r", "\n"):
assert bad not in msg_cell, f"control char {bad!r} survived into the CSV cell"
# ---------------------------------------------------------------------------
# #44 — non-serialisable metadata is dropped best-effort, never raises
# ---------------------------------------------------------------------------
def test_non_serializable_metadata_does_not_raise():
from ledgrab.core.activity_log.recorder import ActivityRecorder
persisted: list = []
repo = MagicMock()
# Route through to_row() so the real json.dumps codec runs (and raises).
repo.record.side_effect = lambda entry: persisted.append(entry.to_row())
recorder = ActivityRecorder(repo, MagicMock(), loop=None)
# Must not raise into the caller despite the un-encodable values.
recorder.record(
category=ActivityCategory.SYSTEM,
action="bad.metadata",
message="m",
metadata={"when": datetime.now(timezone.utc), "tags": {1, 2, 3}},
)
assert persisted == [], "non-serialisable entry must not persist"
+188
View File
@@ -0,0 +1,188 @@
"""Tests for SolarRule (sunrise/sunset automation trigger).
Covers the data model (round-trip + input validation), the engine's
``_evaluate_solar`` window logic (sunrise/sunset patched to fixed hours so the
test is location/clock-independent), the shared solar math, and the API
schema Rule mapping.
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import patch
from ledgrab.api.routes.automations import _rule_from_schema, _rule_to_schema
from ledgrab.api.schemas.automations import RuleSchema
from ledgrab.core.automations import automation_engine
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.storage.automation import Rule, SolarRule
from ledgrab.utils.solar import compute_solar_times
# Fixed solar times the engine math is patched to: sunrise 06:00, sunset 19:00.
_SUNRISE_MIN = 6 * 60 # 360
_SUNSET_MIN = 19 * 60 # 1140
# ── Data model ──────────────────────────────────────────────────────────
class TestSolarRuleModel:
def test_defaults_are_night_window(self):
r = SolarRule()
assert r.rule_type == "solar"
assert r.start_event == "sunset"
assert r.end_event == "sunrise"
assert r.start_offset_minutes == 0 and r.end_offset_minutes == 0
def test_to_dict_from_dict_round_trip(self):
r = SolarRule(
start_event="sunrise",
start_offset_minutes=-30,
end_event="sunset",
end_offset_minutes=45,
latitude=51.5,
longitude=-0.12,
days_of_week=[5, 6],
timezone="Europe/London",
)
back = SolarRule.from_dict(r.to_dict())
assert back == r
def test_from_dict_dispatches_via_base_rule(self):
d = {"rule_type": "solar", "start_event": "sunrise"}
r = Rule.from_dict(d)
assert isinstance(r, SolarRule)
assert r.start_event == "sunrise"
def test_invalid_event_falls_back_to_default(self):
r = SolarRule.from_dict({"start_event": "noon", "end_event": ""})
assert r.start_event == "sunset"
assert r.end_event == "sunrise"
def test_offsets_are_clamped(self):
r = SolarRule.from_dict({"start_offset_minutes": 9000, "end_offset_minutes": -9000})
assert r.start_offset_minutes == 1439
assert r.end_offset_minutes == -1439
def test_offsets_handle_non_numeric(self):
r = SolarRule.from_dict({"start_offset_minutes": None, "end_offset_minutes": "x"})
assert r.start_offset_minutes == 0 and r.end_offset_minutes == 0
def test_coords_are_clamped(self):
r = SolarRule.from_dict({"latitude": 200.0, "longitude": -400.0})
assert r.latitude == 90.0
assert r.longitude == -180.0
def test_days_of_week_filtered_and_sorted(self):
r = SolarRule.from_dict({"days_of_week": [6, 0, 9, -1, "x", 3, 3]})
assert r.days_of_week == [0, 3, 6]
# ── Engine evaluation ───────────────────────────────────────────────────
def _eval_at(dt: datetime, **rule_kwargs) -> bool:
rule = SolarRule.from_dict(rule_kwargs)
with (
patch.object(automation_engine, "compute_solar_times", return_value=(6.0, 19.0)),
patch.object(automation_engine, "_now_in_tz", return_value=dt),
):
return AutomationEngine._evaluate_solar(rule)
class TestSolarEvaluation:
def test_night_window_active_after_sunset(self):
# default sunset→sunrise; 22:00 is inside the evening portion
assert _eval_at(datetime(2026, 6, 15, 22, 0)) is True
def test_night_window_active_before_sunrise(self):
# 03:00 is inside the after-midnight tail
assert _eval_at(datetime(2026, 6, 15, 3, 0)) is True
def test_night_window_inactive_at_noon(self):
assert _eval_at(datetime(2026, 6, 15, 12, 0)) is False
def test_day_window_active_at_noon(self):
assert (
_eval_at(datetime(2026, 6, 15, 12, 0), start_event="sunrise", end_event="sunset")
is True
)
def test_day_window_inactive_at_night(self):
assert (
_eval_at(datetime(2026, 6, 15, 23, 0), start_event="sunrise", end_event="sunset")
is False
)
def test_start_offset_shifts_boundary_earlier(self):
# sunset-30 = 18:30; the window opens earlier
assert _eval_at(datetime(2026, 6, 15, 18, 45), start_offset_minutes=-30) is True
assert _eval_at(datetime(2026, 6, 15, 18, 15), start_offset_minutes=-30) is False
def test_weekday_restriction_evening(self):
dt = datetime(2026, 6, 15, 22, 0) # evening portion → today's weekday
assert _eval_at(dt, days_of_week=[dt.weekday()]) is True
assert _eval_at(dt, days_of_week=[(dt.weekday() + 1) % 7]) is False
def test_weekday_restriction_overnight_tail_uses_previous_day(self):
dt = datetime(2026, 6, 15, 3, 0) # after-midnight tail → previous weekday
assert _eval_at(dt, days_of_week=[(dt.weekday() - 1) % 7]) is True
assert _eval_at(dt, days_of_week=[dt.weekday()]) is False
# ── Shared solar math ───────────────────────────────────────────────────
class TestComputeSolarTimes:
def test_midlatitude_summer_has_sane_window(self):
sunrise, sunset = compute_solar_times(50.0, 0.0, 172, 0.0) # ~solstice
assert 0.5 <= sunrise <= 11.5
assert 12.5 <= sunset <= 23.5
assert sunrise < sunset
def test_polar_winter_is_clamped_not_degenerate(self):
# High latitude, mid-winter: raw equations collapse, but the clamp
# keeps sunrise < sunset so the trigger never sees an empty window.
sunrise, sunset = compute_solar_times(80.0, 0.0, 355, 0.0)
assert sunrise <= 11.5
assert sunset >= 12.5
assert sunrise < sunset
# ── API schema mapping ──────────────────────────────────────────────────
class TestSolarSchemaMapping:
def test_schema_to_rule_and_back_preserves_fields(self):
schema = RuleSchema(
rule_type="solar",
start_event="sunrise",
start_offset_minutes=-15,
end_event="sunset",
end_offset_minutes=15,
latitude=51.5,
longitude=-0.1,
days_of_week=[5, 6],
timezone="Europe/London",
)
rule = _rule_from_schema(schema)
assert isinstance(rule, SolarRule)
assert rule.start_event == "sunrise"
assert rule.end_offset_minutes == 15
assert rule.latitude == 51.5
# Round-trip back to schema must NOT drop the solar fields.
out = _rule_to_schema(rule)
assert out.start_event == "sunrise"
assert out.end_event == "sunset"
assert out.start_offset_minutes == -15
assert out.latitude == 51.5
assert out.timezone == "Europe/London"
def test_schema_to_rule_tolerates_missing_solar_fields(self):
# A bare solar schema (all optional fields None) must still build a
# valid default night-window rule.
rule = _rule_from_schema(RuleSchema(rule_type="solar"))
assert isinstance(rule, SolarRule)
assert rule.start_event == "sunset"
assert rule.end_event == "sunrise"