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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 0–1
|
||||
energy scalar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# RMS of typical program audio sits around 0.05–0.3; scale so a moderately
|
||||
# loud signal reaches ~1.0 before the per-effect intensity slider is applied.
|
||||
_RMS_GAIN = 4.0
|
||||
|
||||
|
||||
class AudioEnergyTap:
|
||||
"""Resolve → acquire → read smoothed loudness from a shared audio capture."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_capture_manager: Any,
|
||||
audio_source_store: Any = None,
|
||||
audio_template_store: Any = None,
|
||||
) -> None:
|
||||
self._mgr = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
self._stream = None
|
||||
self._source_id = ""
|
||||
self._device_index = -1
|
||||
self._loopback = True
|
||||
self._engine_type = None
|
||||
self._engine_config = None
|
||||
self._smoothed = 0.0
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._mgr is not None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self._stream is not None
|
||||
|
||||
def configure(self, audio_source_id: str) -> None:
|
||||
"""Resolve capture params for ``audio_source_id`` (no acquire yet)."""
|
||||
self._source_id = audio_source_id or ""
|
||||
self._device_index = -1
|
||||
self._loopback = True
|
||||
self._engine_type = None
|
||||
self._engine_config = None
|
||||
if not self._source_id or not self._audio_source_store:
|
||||
return
|
||||
try:
|
||||
resolved = self._audio_source_store.resolve_audio_source(self._source_id)
|
||||
self._device_index = resolved.device_index
|
||||
self._loopback = resolved.is_loopback
|
||||
if resolved.audio_template_id and self._audio_template_store:
|
||||
try:
|
||||
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
|
||||
self._engine_type = tpl.engine_type
|
||||
self._engine_config = tpl.engine_config
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"AudioEnergyTap: template %s missing: %s", resolved.audio_template_id, e
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"AudioEnergyTap: failed to resolve audio source %s: %s", self._source_id, e
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._mgr is None or self._stream is not None:
|
||||
return
|
||||
try:
|
||||
self._stream = self._mgr.acquire(
|
||||
self._device_index,
|
||||
self._loopback,
|
||||
engine_type=self._engine_type,
|
||||
engine_config=self._engine_config,
|
||||
)
|
||||
except Exception as e: # acquisition is best-effort — never break the effect
|
||||
logger.warning("AudioEnergyTap: failed to acquire audio capture: %s", e)
|
||||
self._stream = None
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._mgr is not None and self._stream is not None:
|
||||
try:
|
||||
self._mgr.release(self._device_index, self._loopback, engine_type=self._engine_type)
|
||||
except Exception: # release is best-effort
|
||||
pass
|
||||
self._stream = None
|
||||
self._smoothed = 0.0
|
||||
|
||||
def energy(self, smoothing: float = 0.4) -> float:
|
||||
"""Return smoothed loudness in [0, 1] (0 when no capture/analysis yet).
|
||||
|
||||
``smoothing`` is the inertia of the EMA: 0 = instantaneous, →1 = sluggish.
|
||||
"""
|
||||
if self._stream is None:
|
||||
return 0.0
|
||||
analysis = self._stream.get_latest_analysis()
|
||||
raw = 0.0
|
||||
if analysis is not None:
|
||||
raw = min(1.0, max(0.0, float(getattr(analysis, "rms", 0.0)) * _RMS_GAIN))
|
||||
a = max(0.0, min(0.99, smoothing))
|
||||
self._smoothed = self._smoothed * a + raw * (1.0 - a)
|
||||
return self._smoothed
|
||||
@@ -246,6 +246,13 @@ class ColorStripStreamManager:
|
||||
# Inject gradient store for palette resolution
|
||||
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."""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (0–255) → HSV with h in [0,360), s/v in [0,1]. */
|
||||
function _rgbToHsv(rgb: number[]): [number, number, number] {
|
||||
const r = rgb[0] / 255, g = rgb[1] / 255, b = rgb[2] / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
if (max === r) h = ((g - b) / d) % 6;
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
h *= 60;
|
||||
if (h < 0) h += 360;
|
||||
}
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
return [h, s, max];
|
||||
}
|
||||
|
||||
/** HSV (h in [0,360), s/v in [0,1]) → RGB (0–255). */
|
||||
function _hsvToRgb(h: number, s: number, v: number): number[] {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - c;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; }
|
||||
else if (h < 120) { r = x; g = c; }
|
||||
else if (h < 180) { g = c; b = x; }
|
||||
else if (h < 240) { g = x; b = c; }
|
||||
else if (h < 300) { r = x; b = c; }
|
||||
else { r = c; b = x; }
|
||||
return [r, g, b].map(ch => Math.round((ch + m) * 255));
|
||||
}
|
||||
|
||||
const _HARMONY_ROTATIONS: Partial<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;
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"activity_log.msg.audit_log.disabled": "Activity logging disabled",
|
||||
"activity_log.msg.automation.activated": "Automation '{name}' activated",
|
||||
"activity_log.msg.automation.deactivated": "Automation '{name}' deactivated",
|
||||
"activity_log.msg.automation.triggered": "Automation '{name}' manually triggered",
|
||||
"activity_log.msg.server.shutting_down": "Server shutting down",
|
||||
"activity_log.msg.server.restarting": "Server restart requested",
|
||||
"activity_log.msg.server.shutdown_requested": "Server shutdown requested",
|
||||
@@ -96,7 +97,7 @@
|
||||
"activity_log.entity_type.scene_playlist": "Scene Playlist",
|
||||
"activity_log.entity_type.sync_clock": "Sync Clock",
|
||||
"activity_log.entity_type.template": "Template",
|
||||
"activity_log.entity_type.gradient": "Gradient",
|
||||
"activity_log.entity_type.gradient": "Palette",
|
||||
"activity_log.entity_type.cspt": "Processing Template",
|
||||
"activity_log.entity_type.audio_template": "Audio Template",
|
||||
"activity_log.entity_type.audio_processing_template": "Audio Processing Template",
|
||||
@@ -336,6 +337,7 @@
|
||||
"automation.disabled": "Automation disabled",
|
||||
"automation.enabled": "Automation enabled",
|
||||
"automations.action.disable": "Disable",
|
||||
"automations.action.trigger": "Trigger",
|
||||
"automations.add": "Add Automation",
|
||||
"automations.created": "Automation created",
|
||||
"automations.deactivation_mode": "Deactivation:",
|
||||
@@ -360,6 +362,7 @@
|
||||
"automations.error.name_required": "Name is required",
|
||||
"automations.error.save_failed": "Failed to save automation",
|
||||
"automations.error.toggle_failed": "Failed to toggle automation",
|
||||
"automations.error.trigger_failed": "Failed to trigger automation",
|
||||
"automations.last_activated": "Last activated",
|
||||
"automations.logic.all": "ALL",
|
||||
"automations.logic.and": " AND ",
|
||||
@@ -427,6 +430,9 @@
|
||||
"automations.rule.http_poll.value": "Value",
|
||||
"automations.rule.http_poll.value.placeholder": "playing",
|
||||
"automations.rule.http_poll.value_source": "HTTP Value Source",
|
||||
"automations.rule.manual_trigger": "Manual Trigger",
|
||||
"automations.rule.manual_trigger.desc": "Run from a button",
|
||||
"automations.rule.manual_trigger.hint": "Activates only when you press the Trigger button on the automation card (the automation's other rules are still checked).",
|
||||
"automations.rule.mqtt": "MQTT",
|
||||
"automations.rule.mqtt.desc": "MQTT message",
|
||||
"automations.rule.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
|
||||
@@ -447,6 +453,18 @@
|
||||
"automations.rule.system_idle.when_active.desc": "Fires while the user is actively using the system",
|
||||
"automations.rule.system_idle.when_idle": "When idle",
|
||||
"automations.rule.system_idle.when_idle.desc": "Fires once the user has been idle past the timeout",
|
||||
"automations.rule.solar": "Sun (Sunrise / Sunset)",
|
||||
"automations.rule.solar.desc": "Relative to sunrise/sunset",
|
||||
"automations.rule.solar.hint": "Activate during a window relative to sunrise and sunset at your location. The default — sunset to sunrise — covers \"active at night\".",
|
||||
"automations.rule.solar.start": "Window opens at",
|
||||
"automations.rule.solar.end": "Window closes at",
|
||||
"automations.rule.solar.event.sunrise": "Sunrise",
|
||||
"automations.rule.solar.event.sunset": "Sunset",
|
||||
"automations.rule.solar.offset": "Offset in minutes (negative = before the event, positive = after)",
|
||||
"automations.rule.solar.offset.unit": "min",
|
||||
"automations.rule.solar.latitude": "Latitude",
|
||||
"automations.rule.solar.longitude": "Longitude",
|
||||
"automations.rule.solar.location_hint": "Set your latitude and longitude so sunrise and sunset are computed for your location.",
|
||||
"automations.rule.time_of_day": "Time of Day",
|
||||
"automations.rule.time_of_day.days": "Active days",
|
||||
"automations.rule.time_of_day.days_hint": "Leave all unselected for every day. Overnight windows count toward the day they start on.",
|
||||
@@ -485,6 +503,10 @@
|
||||
"automations.status.disabled": "Disabled",
|
||||
"automations.status.inactive": "Inactive",
|
||||
"automations.title": "Automations",
|
||||
"automations.trigger.partial": "Triggered with errors",
|
||||
"automations.trigger.skipped": "Conditions not met — automation not triggered",
|
||||
"automations.trigger.tooltip": "Run this automation now (its rules are still checked)",
|
||||
"automations.triggered": "Automation triggered",
|
||||
"automations.updated": "Automation updated",
|
||||
"bg.anim.toggle": "Toggle ambient background",
|
||||
"bindable.none": "None (static value)",
|
||||
@@ -524,6 +546,10 @@
|
||||
"calibration.advanced.switch_to_simple": "Switch to Simple",
|
||||
"calibration.advanced.title": "Advanced Calibration",
|
||||
"calibration.border_width": "Border (px):",
|
||||
"calibration.linear_blend": "Linear-light blending",
|
||||
"calibration.linear_blend.hint": "Average border pixels in linear light for perceptually correct, brighter colour mixing.",
|
||||
"calibration.dither": "Dithering",
|
||||
"calibration.dither.hint": "Spatio-temporal dithering reduces visible banding on smooth gradients.",
|
||||
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
||||
"calibration.button.cancel": "Cancel",
|
||||
"calibration.button.save": "Save",
|
||||
@@ -764,6 +790,14 @@
|
||||
"color_strip.effect.meteor": "Meteor",
|
||||
"color_strip.effect.meteor.desc": "Bright head travels along the strip with an exponential-decay tail",
|
||||
"color_strip.effect.mirror": "Mirror:",
|
||||
"color_strip.effect.reactive": "Audio reactive:",
|
||||
"color_strip.effect.reactive.hint": "Modulate this effect's brightness and/or saturation with live audio loudness.",
|
||||
"color_strip.effect.reactive.source": "Audio source:",
|
||||
"color_strip.effect.reactive.mode": "Modulate:",
|
||||
"color_strip.effect.reactive.mode.brightness": "Brightness",
|
||||
"color_strip.effect.reactive.mode.saturation": "Saturation",
|
||||
"color_strip.effect.reactive.mode.both": "Both",
|
||||
"color_strip.effect.reactive.intensity": "Strength:",
|
||||
"color_strip.effect.mirror.hint": "Bounce mode — the meteor reverses direction at strip ends instead of wrapping.",
|
||||
"color_strip.effect.noise": "Noise",
|
||||
"color_strip.effect.noise.desc": "Scrolling fractal value noise mapped to a palette",
|
||||
@@ -812,8 +846,8 @@
|
||||
"color_strip.gradient.easing.linear.desc": "Constant-rate blending between stops",
|
||||
"color_strip.gradient.easing.step": "Step",
|
||||
"color_strip.gradient.easing.step.desc": "Hard jumps between colors with no blending",
|
||||
"color_strip.gradient.error.no_gradient": "Please select a gradient",
|
||||
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops",
|
||||
"color_strip.gradient.error.no_gradient": "Please select a palette",
|
||||
"color_strip.gradient.min_stops": "Palette must have at least 2 stops",
|
||||
"color_strip.gradient.position": "Position (0.0–1.0)",
|
||||
"color_strip.gradient.preset": "Preset:",
|
||||
"color_strip.gradient.preset.apply": "Apply",
|
||||
@@ -837,8 +871,17 @@
|
||||
"color_strip.gradient.preset.warm": "Warm",
|
||||
"color_strip.gradient.preview": "Gradient:",
|
||||
"color_strip.gradient.preview.hint": "Visual preview. Click the marker track below to add a stop. Drag markers to reposition.",
|
||||
"color_strip.gradient.select": "Gradient:",
|
||||
"color_strip.gradient.select.hint": "Select a gradient from the library. Create and edit gradients in the Gradients tab.",
|
||||
"color_strip.gradient.select": "Palette:",
|
||||
"color_strip.gradient.select.hint": "Select a palette from the library. Create and edit palettes in the Palettes tab.",
|
||||
"color_strip.gradient.harmony": "Color harmony:",
|
||||
"color_strip.gradient.harmony.hint": "Pick a base color, then generate a harmonious set of stops from classic color-theory relationships.",
|
||||
"color_strip.gradient.harmony.base": "Base color",
|
||||
"color_strip.gradient.harmony.complementary": "Complementary",
|
||||
"color_strip.gradient.harmony.analogous": "Analogous",
|
||||
"color_strip.gradient.harmony.triadic": "Triadic",
|
||||
"color_strip.gradient.harmony.split_complementary": "Split",
|
||||
"color_strip.gradient.harmony.tetradic": "Tetradic",
|
||||
"color_strip.gradient.harmony.monochromatic": "Mono",
|
||||
"color_strip.gradient.stops": "Color Stops:",
|
||||
"color_strip.gradient.stops.hint": "Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.",
|
||||
"color_strip.gradient.stops_count": "stops",
|
||||
@@ -1371,7 +1414,7 @@
|
||||
"device.icon.entity.cspt": "Color-strip processing template",
|
||||
"device.icon.entity.device": "Device",
|
||||
"device.icon.entity.game_integration": "Game integration",
|
||||
"device.icon.entity.gradient": "Gradient",
|
||||
"device.icon.entity.gradient": "Palette",
|
||||
"device.icon.entity.ha_light_target": "HA light target",
|
||||
"device.icon.entity.ha_source": "Home Assistant source",
|
||||
"device.icon.entity.http_endpoint": "HTTP endpoint",
|
||||
@@ -1498,6 +1541,8 @@
|
||||
"device.nanoleaf.url.placeholder": "192.168.1.50",
|
||||
"device.nanoleaf_min_interval": "Min Update Interval:",
|
||||
"device.nanoleaf_min_interval.hint": "Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.",
|
||||
"device.nanoleaf_per_panel": "Per-panel streaming:",
|
||||
"device.nanoleaf_per_panel.hint": "Stream each panel individually via extControl UDP instead of one averaged colour. Requires a recent controller firmware.",
|
||||
"device.opc.url": "IP Address:",
|
||||
"device.opc.url.hint": "OPC receiver address. TCP port defaults to 7890.",
|
||||
"device.opc.url.placeholder": "192.168.1.50",
|
||||
@@ -1806,26 +1851,26 @@
|
||||
"game_integration.test.timeout": "No events received within timeout period.",
|
||||
"game_integration.test.waiting": "Waiting for events from game...",
|
||||
"game_integration.updated": "Game integration updated",
|
||||
"gradient.add": "Add Gradient",
|
||||
"gradient.add": "Add Palette",
|
||||
"gradient.builtin": "Built-in",
|
||||
"gradient.cloned": "Gradient cloned",
|
||||
"gradient.confirm_delete": "Delete gradient \"{name}\"?",
|
||||
"gradient.create_name": "New gradient name:",
|
||||
"gradient.created": "Gradient created",
|
||||
"gradient.deleted": "Gradient deleted",
|
||||
"gradient.cloned": "Palette cloned",
|
||||
"gradient.confirm_delete": "Delete palette \"{name}\"?",
|
||||
"gradient.create_name": "New palette name:",
|
||||
"gradient.created": "Palette created",
|
||||
"gradient.deleted": "Palette deleted",
|
||||
"gradient.description": "Description:",
|
||||
"gradient.description.hint": "Optional description for this gradient.",
|
||||
"gradient.edit": "Edit Gradient",
|
||||
"gradient.edit_name": "Rename gradient:",
|
||||
"gradient.error.delete_failed": "Failed to delete gradient",
|
||||
"gradient.description.hint": "Optional description for this palette.",
|
||||
"gradient.edit": "Edit Palette",
|
||||
"gradient.edit_name": "Rename palette:",
|
||||
"gradient.error.delete_failed": "Failed to delete palette",
|
||||
"gradient.error.min_stops": "At least 2 color stops are required",
|
||||
"gradient.error.name_required": "Name is required",
|
||||
"gradient.error.save_failed": "Failed to save gradient",
|
||||
"gradient.group.title": "Gradients",
|
||||
"gradient.error.save_failed": "Failed to save palette",
|
||||
"gradient.group.title": "Palettes",
|
||||
"gradient.name": "Name:",
|
||||
"gradient.name.hint": "A descriptive name for this gradient.",
|
||||
"gradient.name.hint": "A descriptive name for this palette.",
|
||||
"gradient.stops_label": "stops",
|
||||
"gradient.updated": "Gradient updated",
|
||||
"gradient.updated": "Palette updated",
|
||||
"graph.action.connect": "Connect",
|
||||
"graph.action.disconnect": "Disconnect",
|
||||
"graph.action.move": "Move node",
|
||||
@@ -2323,7 +2368,7 @@
|
||||
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
|
||||
"section.empty.devices": "No devices yet. Click + to add one.",
|
||||
"section.empty.game_integrations": "No game integrations yet. Click + to create one.",
|
||||
"section.empty.gradients": "No gradients yet",
|
||||
"section.empty.gradients": "No palettes yet",
|
||||
"section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
|
||||
"section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.",
|
||||
"section.empty.http_endpoints": "No HTTP endpoints yet. Click + to add one.",
|
||||
@@ -2581,7 +2626,7 @@
|
||||
"settings.section.file": "File",
|
||||
"settings.section.filtering": "Filtering",
|
||||
"settings.section.filters": "Filters",
|
||||
"settings.section.gradient": "Gradient",
|
||||
"settings.section.gradient": "Palette",
|
||||
"settings.section.hardware": "Hardware",
|
||||
"settings.section.history": "History",
|
||||
"settings.section.identity": "Identity",
|
||||
@@ -2597,6 +2642,7 @@
|
||||
"settings.section.notif_permission": "OS Permission",
|
||||
"settings.section.offsets": "Offsets",
|
||||
"settings.section.output": "Output",
|
||||
"settings.section.power": "Power",
|
||||
"settings.section.preview": "Preview",
|
||||
"settings.section.protocol": "Protocol",
|
||||
"settings.section.provider": "Provider",
|
||||
@@ -2674,7 +2720,7 @@
|
||||
"streams.group.color_strip": "Color Strips",
|
||||
"streams.group.css_processing": "Processing Templates",
|
||||
"streams.group.game": "Game Integration",
|
||||
"streams.group.gradients": "Gradients",
|
||||
"streams.group.gradients": "Palettes",
|
||||
"streams.group.home_assistant": "Home Assistant",
|
||||
"streams.group.http": "HTTP",
|
||||
"streams.group.mqtt": "MQTT",
|
||||
@@ -2970,7 +3016,7 @@
|
||||
"tour.src.static": "Static Image — test your setup with image files instead of live capture.",
|
||||
"tour.src.sync": "Sync Clocks — shared timers that synchronize animations across multiple sources.",
|
||||
"tour.src.templates": "Capture Templates — reusable capture configurations (resolution, FPS, crop).",
|
||||
"tour.src.value": "Value Sources — dynamic numbers or colors driven by time of day, audio, system metrics, Home Assistant entities, gradients, or schedules. Used to animate effects, modulate color strips, and trigger automations.",
|
||||
"tour.src.value": "Value Sources — dynamic numbers or colors driven by time of day, audio, system metrics, Home Assistant entities, palettes, or schedules. Used to animate effects, modulate color strips, and trigger automations.",
|
||||
"tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.",
|
||||
"tour.tgt.css": "Color Strips — define how screen regions map to LED segments.",
|
||||
"tour.tgt.devices": "Devices — your LED controllers discovered on the network.",
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
"activity_log.filter.since": "С",
|
||||
"activity_log.filter.title": "Фильтры",
|
||||
"activity_log.filter.until": "По",
|
||||
"activity_log.live": "Live",
|
||||
"activity_log.live": "В эфире",
|
||||
"z2m_light.bulbs.one": "{count} лампа",
|
||||
"z2m_light.bulbs.few": "{count} лампы",
|
||||
"z2m_light.bulbs.many": "{count} ламп",
|
||||
"activity_log.load_more": "Загрузить ещё",
|
||||
"activity_log.loading": "Загрузка журнала…",
|
||||
"activity_log.n_entries": "Записей: {n}",
|
||||
@@ -75,6 +78,7 @@
|
||||
"activity_log.msg.audit_log.disabled": "Запись активности отключена",
|
||||
"activity_log.msg.automation.activated": "Автоматизация '{name}' активирована",
|
||||
"activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована",
|
||||
"activity_log.msg.automation.triggered": "Автоматизация '{name}' запущена вручную",
|
||||
"activity_log.msg.server.shutting_down": "Сервер выключается",
|
||||
"activity_log.msg.server.restarting": "Запрошен перезапуск сервера",
|
||||
"activity_log.msg.server.shutdown_requested": "Запрошено выключение сервера",
|
||||
@@ -96,7 +100,7 @@
|
||||
"activity_log.entity_type.scene_playlist": "Плейлист сцен",
|
||||
"activity_log.entity_type.sync_clock": "Синх-часы",
|
||||
"activity_log.entity_type.template": "Шаблон",
|
||||
"activity_log.entity_type.gradient": "Градиент",
|
||||
"activity_log.entity_type.gradient": "Палитра",
|
||||
"activity_log.entity_type.cspt": "Шаблон обработки",
|
||||
"activity_log.entity_type.audio_template": "Аудиошаблон",
|
||||
"activity_log.entity_type.audio_processing_template": "Шаблон аудиообработки",
|
||||
@@ -284,8 +288,8 @@
|
||||
"auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к LED Grab.",
|
||||
"auth.placeholder": "Введите ваш API ключ...",
|
||||
"auth.please_login": "Пожалуйста, войдите для просмотра",
|
||||
"auth.prompt_enter": "Enter your API key:",
|
||||
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
|
||||
"auth.prompt_enter": "Введите ваш API-ключ:",
|
||||
"auth.prompt_update": "API-ключ уже задан. Введите новый ключ для замены или оставьте поле пустым, чтобы удалить:",
|
||||
"auth.session_expired": "Ваша сессия истекла или API ключ недействителен. Пожалуйста, войдите снова.",
|
||||
"auth.success": "Вход выполнен успешно!",
|
||||
"auth.title": "Вход в LED Grab",
|
||||
@@ -336,6 +340,7 @@
|
||||
"automation.disabled": "Автоматизация выключена",
|
||||
"automation.enabled": "Автоматизация включена",
|
||||
"automations.action.disable": "Отключить",
|
||||
"automations.action.trigger": "Запустить",
|
||||
"automations.add": "Добавить автоматизацию",
|
||||
"automations.created": "Автоматизация создана",
|
||||
"automations.deactivation_mode": "Деактивация:",
|
||||
@@ -360,6 +365,7 @@
|
||||
"automations.error.name_required": "Введите название",
|
||||
"automations.error.save_failed": "Не удалось сохранить автоматизацию",
|
||||
"automations.error.toggle_failed": "Не удалось переключить автоматизацию",
|
||||
"automations.error.trigger_failed": "Не удалось запустить автоматизацию",
|
||||
"automations.last_activated": "Последняя активация",
|
||||
"automations.logic.all": "ВСЕ",
|
||||
"automations.logic.and": " И ",
|
||||
@@ -417,6 +423,9 @@
|
||||
"automations.rule.http_poll.value": "Значение",
|
||||
"automations.rule.http_poll.value.placeholder": "playing",
|
||||
"automations.rule.http_poll.value_source": "HTTP источник-значения",
|
||||
"automations.rule.manual_trigger": "Ручной запуск",
|
||||
"automations.rule.manual_trigger.desc": "Запуск по кнопке",
|
||||
"automations.rule.manual_trigger.hint": "Активируется только при нажатии кнопки «Запустить» на карточке автоматизации (остальные правила автоматизации по-прежнему проверяются).",
|
||||
"automations.rule.mqtt": "MQTT",
|
||||
"automations.rule.mqtt.desc": "MQTT сообщение",
|
||||
"automations.rule.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
|
||||
@@ -437,6 +446,18 @@
|
||||
"automations.rule.system_idle.when_active.desc": "Срабатывает, пока пользователь активно работает с системой",
|
||||
"automations.rule.system_idle.when_idle": "При бездействии",
|
||||
"automations.rule.system_idle.when_idle.desc": "Срабатывает, когда пользователь не активен дольше тайм-аута",
|
||||
"automations.rule.solar": "Солнце (восход / закат)",
|
||||
"automations.rule.solar.desc": "Относительно восхода/заката",
|
||||
"automations.rule.solar.hint": "Активируется в окне относительно восхода и заката в вашем расположении. По умолчанию — от заката до восхода — это режим «активно ночью».",
|
||||
"automations.rule.solar.start": "Окно открывается в",
|
||||
"automations.rule.solar.end": "Окно закрывается в",
|
||||
"automations.rule.solar.event.sunrise": "Восход",
|
||||
"automations.rule.solar.event.sunset": "Закат",
|
||||
"automations.rule.solar.offset": "Смещение в минутах (отрицательное — до события, положительное — после)",
|
||||
"automations.rule.solar.offset.unit": "мин",
|
||||
"automations.rule.solar.latitude": "Широта",
|
||||
"automations.rule.solar.longitude": "Долгота",
|
||||
"automations.rule.solar.location_hint": "Укажите широту и долготу, чтобы восход и закат вычислялись для вашего расположения.",
|
||||
"automations.rule.time_of_day": "Время суток",
|
||||
"automations.rule.time_of_day.days": "Активные дни",
|
||||
"automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.",
|
||||
@@ -475,6 +496,10 @@
|
||||
"automations.status.disabled": "Отключена",
|
||||
"automations.status.inactive": "Неактивна",
|
||||
"automations.title": "Автоматизации",
|
||||
"automations.trigger.partial": "Запущено с ошибками",
|
||||
"automations.trigger.skipped": "Условия не выполнены — автоматизация не запущена",
|
||||
"automations.trigger.tooltip": "Запустить эту автоматизацию сейчас (правила по-прежнему проверяются)",
|
||||
"automations.triggered": "Автоматизация запущена",
|
||||
"automations.updated": "Автоматизация обновлена",
|
||||
"bg.anim.toggle": "Анимированный фон",
|
||||
"bindable.none": "Нет (статическое значение)",
|
||||
@@ -516,6 +541,10 @@
|
||||
"calibration.advanced.switch_to_simple": "Простой режим",
|
||||
"calibration.advanced.title": "Расширенная калибровка",
|
||||
"calibration.border_width": "Граница (px):",
|
||||
"calibration.linear_blend": "Смешивание в линейном свете",
|
||||
"calibration.linear_blend.hint": "Усреднять пиксели границы в линейном свете для перцептивно корректного, более яркого смешивания цветов.",
|
||||
"calibration.dither": "Дизеринг",
|
||||
"calibration.dither.hint": "Пространственно-временной дизеринг уменьшает заметные полосы на плавных градиентах.",
|
||||
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
||||
"calibration.button.cancel": "Отмена",
|
||||
"calibration.button.save": "Сохранить",
|
||||
@@ -728,6 +757,14 @@
|
||||
"color_strip.effect.meteor": "Метеор",
|
||||
"color_strip.effect.meteor.desc": "Яркая точка движется по ленте с экспоненциально затухающим хвостом",
|
||||
"color_strip.effect.mirror": "Отражение:",
|
||||
"color_strip.effect.reactive": "Реакция на звук:",
|
||||
"color_strip.effect.reactive.hint": "Модулировать яркость и/или насыщенность этого эффекта по громкости звука в реальном времени.",
|
||||
"color_strip.effect.reactive.source": "Источник звука:",
|
||||
"color_strip.effect.reactive.mode": "Модуляция:",
|
||||
"color_strip.effect.reactive.mode.brightness": "Яркость",
|
||||
"color_strip.effect.reactive.mode.saturation": "Насыщенность",
|
||||
"color_strip.effect.reactive.mode.both": "Обе",
|
||||
"color_strip.effect.reactive.intensity": "Сила:",
|
||||
"color_strip.effect.mirror.hint": "Режим отскока — метеор меняет направление у краёв ленты вместо переноса.",
|
||||
"color_strip.effect.noise": "Шум",
|
||||
"color_strip.effect.noise.desc": "Прокручиваемый фрактальный шум, отображённый на палитру",
|
||||
@@ -760,7 +797,7 @@
|
||||
"color_strip.gamma.hint": "Гамма-коррекция (1=без коррекции, \u003c1=ярче средние тона, \u003e1=темнее средние тона)",
|
||||
"color_strip.gradient.add_stop": "+ Добавить",
|
||||
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
|
||||
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок",
|
||||
"color_strip.gradient.min_stops": "Палитра должна содержать не менее 2 остановок",
|
||||
"color_strip.gradient.position": "Позиция (0.0–1.0)",
|
||||
"color_strip.gradient.preset": "Пресет:",
|
||||
"color_strip.gradient.preset.apply": "Применить",
|
||||
@@ -784,6 +821,15 @@
|
||||
"color_strip.gradient.preset.warm": "Тёплый",
|
||||
"color_strip.gradient.preview": "Градиент:",
|
||||
"color_strip.gradient.preview.hint": "Предпросмотр градиента. Нажмите на дорожку маркеров чтобы добавить остановку. Перетащите маркеры для изменения позиции.",
|
||||
"color_strip.gradient.harmony": "Цветовая гармония:",
|
||||
"color_strip.gradient.harmony.hint": "Выберите базовый цвет, затем создайте гармоничный набор точек на основе классических цветовых сочетаний.",
|
||||
"color_strip.gradient.harmony.base": "Базовый цвет",
|
||||
"color_strip.gradient.harmony.complementary": "Контрастная",
|
||||
"color_strip.gradient.harmony.analogous": "Аналоговая",
|
||||
"color_strip.gradient.harmony.triadic": "Триада",
|
||||
"color_strip.gradient.harmony.split_complementary": "Разделённая",
|
||||
"color_strip.gradient.harmony.tetradic": "Тетрада",
|
||||
"color_strip.gradient.harmony.monochromatic": "Моно",
|
||||
"color_strip.gradient.stops": "Цветовые остановки:",
|
||||
"color_strip.gradient.stops.hint": "Каждая остановка задаёт цвет в относительной позиции (0.0 = начало, 1.0 = конец). Кнопка ↔ добавляет цвет справа для создания резкого перехода.",
|
||||
"color_strip.gradient.stops_count": "остановок",
|
||||
@@ -1290,7 +1336,7 @@
|
||||
"device.icon.entity.cspt": "Шаблон обработки полоски",
|
||||
"device.icon.entity.device": "Устройство",
|
||||
"device.icon.entity.game_integration": "Игровая интеграция",
|
||||
"device.icon.entity.gradient": "Градиент",
|
||||
"device.icon.entity.gradient": "Палитра",
|
||||
"device.icon.entity.ha_light_target": "HA-светильник",
|
||||
"device.icon.entity.ha_source": "Источник Home Assistant",
|
||||
"device.icon.entity.http_endpoint": "HTTP-эндпоинт",
|
||||
@@ -1417,6 +1463,8 @@
|
||||
"device.nanoleaf.url.placeholder": "192.168.1.50",
|
||||
"device.nanoleaf_min_interval": "Мин. интервал обновления:",
|
||||
"device.nanoleaf_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 100 мс ≈ 10 Гц; накладные расходы HTTP ограничивают практический максимум ~20 Гц.",
|
||||
"device.nanoleaf_per_panel": "Потоковая передача по панелям:",
|
||||
"device.nanoleaf_per_panel.hint": "Передавать цвет каждой панели отдельно через extControl UDP вместо одного усреднённого цвета. Требуется недавняя прошивка контроллера.",
|
||||
"device.opc.url": "IP-адрес:",
|
||||
"device.opc.url.hint": "Адрес приёмника OPC. TCP-порт по умолчанию 7890.",
|
||||
"device.opc.url.placeholder": "192.168.1.50",
|
||||
@@ -1706,8 +1754,8 @@
|
||||
"game_integration.test.timeout": "События не получены за отведённое время.",
|
||||
"game_integration.test.waiting": "Ожидание событий от игры...",
|
||||
"game_integration.updated": "Игровая интеграция обновлена",
|
||||
"gradient.error.delete_failed": "Не удалось удалить градиент",
|
||||
"gradient.error.save_failed": "Не удалось сохранить градиент",
|
||||
"gradient.error.delete_failed": "Не удалось удалить палитру",
|
||||
"gradient.error.save_failed": "Не удалось сохранить палитру",
|
||||
"graph.action.connect": "Соединить",
|
||||
"graph.action.disconnect": "Отсоединить",
|
||||
"graph.action.move": "Переместить узел",
|
||||
@@ -2403,7 +2451,7 @@
|
||||
"settings.section.file": "Файл",
|
||||
"settings.section.filtering": "Фильтрация",
|
||||
"settings.section.filters": "Фильтры",
|
||||
"settings.section.gradient": "Градиент",
|
||||
"settings.section.gradient": "Палитра",
|
||||
"settings.section.hardware": "Оборудование",
|
||||
"settings.section.history": "История",
|
||||
"settings.section.identity": "Идентификация",
|
||||
@@ -2419,6 +2467,7 @@
|
||||
"settings.section.notif_permission": "Разрешение ОС",
|
||||
"settings.section.offsets": "Смещения",
|
||||
"settings.section.output": "Вывод",
|
||||
"settings.section.power": "Питание",
|
||||
"settings.section.preview": "Превью",
|
||||
"settings.section.protocol": "Протокол",
|
||||
"settings.section.provider": "Провайдер",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">×</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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests for linear-light blending in the per-LED reduction.
|
||||
|
||||
Verifies the sRGB↔linear conversion correctness and that the edge-reduction
|
||||
kernel, when ``linear=True``, blends in linear light (a mid-grey from black +
|
||||
white is brighter than the gamma-space mean) — plus the CalibrationConfig
|
||||
round-trip of the opt-in flag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.capture.calibration import calibration_from_dict, calibration_to_dict
|
||||
from ledgrab.core.capture.edge_interpolation import average_edge_to_leds
|
||||
from ledgrab.utils.linear_light import (
|
||||
SRGB_TO_LINEAR_LUT,
|
||||
linear_to_srgb_uint8,
|
||||
srgb_to_linear,
|
||||
)
|
||||
|
||||
|
||||
class TestConversions:
|
||||
def test_lut_endpoints_and_monotonic(self):
|
||||
assert SRGB_TO_LINEAR_LUT.shape == (256,)
|
||||
assert SRGB_TO_LINEAR_LUT[0] == 0.0
|
||||
assert abs(SRGB_TO_LINEAR_LUT[255] - 1.0) < 1e-6
|
||||
assert np.all(np.diff(SRGB_TO_LINEAR_LUT) > 0) # strictly increasing
|
||||
|
||||
def test_mid_grey_decodes_below_half(self):
|
||||
# sRGB 0.5 (≈128) is ~0.214 in linear light, not 0.5.
|
||||
assert 0.18 < float(SRGB_TO_LINEAR_LUT[128]) < 0.25
|
||||
|
||||
def test_round_trip_is_near_identity(self):
|
||||
ramp = np.arange(256, dtype=np.uint8)
|
||||
back = linear_to_srgb_uint8(srgb_to_linear(ramp))
|
||||
assert np.max(np.abs(back.astype(int) - ramp.astype(int))) <= 1
|
||||
|
||||
def test_linear_mean_of_black_and_white_is_brighter(self):
|
||||
lin = (srgb_to_linear(np.array([0, 255], dtype=np.uint8))).mean()
|
||||
encoded = int(linear_to_srgb_uint8(np.array([lin], dtype=np.float32))[0])
|
||||
assert encoded > 127 # brighter than the sRGB mean (127)
|
||||
assert 180 < encoded < 195 # ~188
|
||||
|
||||
|
||||
class TestEdgeReductionLinear:
|
||||
def _edge(self):
|
||||
# top edge (axis=0): shape (rows=2, width=4, 3); two black + two white cols
|
||||
e = np.zeros((2, 4, 3), dtype=np.uint8)
|
||||
e[:, 2:, :] = 255
|
||||
return e
|
||||
|
||||
def test_srgb_blend_is_plain_mean(self):
|
||||
out = average_edge_to_leds(self._edge(), "top", 1, {}, "k", linear=False)
|
||||
assert int(out[0, 0]) == 127
|
||||
|
||||
def test_linear_blend_is_brighter(self):
|
||||
out = average_edge_to_leds(self._edge(), "top", 1, {}, "k", linear=True)
|
||||
assert int(out[0, 0]) > 127
|
||||
assert 180 < int(out[0, 0]) < 195
|
||||
|
||||
def test_uniform_edge_unchanged_by_linear(self):
|
||||
e = np.full((2, 4, 3), 200, dtype=np.uint8)
|
||||
out = average_edge_to_leds(e, "top", 1, {}, "k", linear=True)
|
||||
# A flat colour survives the decode→mean→encode round-trip (±1).
|
||||
assert abs(int(out[0, 0]) - 200) <= 1
|
||||
|
||||
|
||||
class TestCalibrationRoundTrip:
|
||||
def test_linear_blend_round_trips_simple(self):
|
||||
cfg = calibration_from_dict(
|
||||
{
|
||||
"mode": "simple",
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"leds_top": 5,
|
||||
"linear_blend": True,
|
||||
}
|
||||
)
|
||||
assert cfg.linear_blend is True
|
||||
assert calibration_to_dict(cfg).get("linear_blend") is True
|
||||
|
||||
def test_default_is_off_and_omitted(self):
|
||||
cfg = calibration_from_dict(
|
||||
{
|
||||
"mode": "simple",
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"leds_top": 5,
|
||||
}
|
||||
)
|
||||
assert cfg.linear_blend is False
|
||||
assert "linear_blend" not in calibration_to_dict(cfg)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for Nanoleaf extControl v2 per-panel streaming.
|
||||
|
||||
The device-side (panelLayout fetch, extControl enable, UDP send) needs a real
|
||||
controller to validate; here we lock down the parts that DON'T: panel
|
||||
ordering, strip→panel resampling, the exact UDP packet framing, and the
|
||||
``nanoleaf_per_panel`` config round-trip through the device store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from ledgrab.core.devices.nanoleaf_client import (
|
||||
build_extcontrol_v2_packet,
|
||||
map_pixels_to_panels,
|
||||
order_panels,
|
||||
)
|
||||
from ledgrab.storage.device_store import Device
|
||||
|
||||
|
||||
class TestOrderPanels:
|
||||
def test_sorts_by_x_then_y_and_drops_controller(self):
|
||||
position = [
|
||||
{"panelId": 5, "x": 100, "y": 0},
|
||||
{"panelId": 3, "x": 0, "y": 0},
|
||||
{"panelId": 0, "x": 50, "y": 50}, # controller / rhythm → dropped
|
||||
{"panelId": 7, "x": 0, "y": 100},
|
||||
]
|
||||
assert order_panels(position) == [3, 7, 5]
|
||||
|
||||
def test_ignores_non_integer_panel_ids(self):
|
||||
assert order_panels([{"panelId": "x", "x": 0, "y": 0}, {"x": 1, "y": 1}]) == []
|
||||
|
||||
|
||||
class TestMapPixelsToPanels:
|
||||
def test_nearest_neighbour_resample(self):
|
||||
pixels = [[10, 10, 10], [20, 20, 20], [30, 30, 30], [40, 40, 40]]
|
||||
out = map_pixels_to_panels(pixels, [1, 2])
|
||||
assert out == [(1, 10, 10, 10), (2, 30, 30, 30)]
|
||||
|
||||
def test_empty_strip_is_black(self):
|
||||
assert map_pixels_to_panels([], [9, 8]) == [(9, 0, 0, 0), (8, 0, 0, 0)]
|
||||
|
||||
def test_more_panels_than_pixels_repeats(self):
|
||||
out = map_pixels_to_panels([[255, 0, 0]], [1, 2, 3])
|
||||
assert out == [(1, 255, 0, 0), (2, 255, 0, 0), (3, 255, 0, 0)]
|
||||
|
||||
|
||||
class TestPacket:
|
||||
def test_framing_is_byte_exact(self):
|
||||
panels = [(100, 10, 20, 30), (200, 40, 50, 60)]
|
||||
pkt = build_extcontrol_v2_packet(panels)
|
||||
assert len(pkt) == 2 + 2 * 8 # uint16 header + 8 bytes/panel
|
||||
assert struct.unpack(">H", pkt[0:2])[0] == 2
|
||||
pid, r, g, b, w, trans = struct.unpack(">HBBBBH", pkt[2:10])
|
||||
assert (pid, r, g, b, w, trans) == (100, 10, 20, 30, 0, 1)
|
||||
pid2, r2, g2, b2, w2, trans2 = struct.unpack(">HBBBBH", pkt[10:18])
|
||||
assert (pid2, r2, g2, b2, w2, trans2) == (200, 40, 50, 60, 0, 1)
|
||||
|
||||
def test_values_are_masked_to_byte_range(self):
|
||||
pkt = build_extcontrol_v2_packet([(70000, 300, -5, 256)])
|
||||
pid, r, g, b, w, trans = struct.unpack(">HBBBBH", pkt[2:10])
|
||||
assert pid == 70000 & 0xFFFF
|
||||
assert r == 300 & 0xFF and b == 256 & 0xFF
|
||||
|
||||
|
||||
class TestConfigRoundTrip:
|
||||
def _device(self, per_panel: bool) -> Device:
|
||||
return Device(
|
||||
device_id="d1",
|
||||
name="Shapes",
|
||||
url="nanoleaf://1.2.3.4",
|
||||
led_count=10,
|
||||
device_type="nanoleaf",
|
||||
nanoleaf_token="tok",
|
||||
nanoleaf_per_panel=per_panel,
|
||||
)
|
||||
|
||||
def test_per_panel_round_trips_through_store(self):
|
||||
d = self._device(True)
|
||||
assert d.to_dict().get("nanoleaf_per_panel") is True
|
||||
back = Device.from_dict(d.to_dict())
|
||||
assert back.nanoleaf_per_panel is True
|
||||
assert back.to_config().nanoleaf_per_panel is True
|
||||
|
||||
def test_default_off_is_omitted(self):
|
||||
d = self._device(False)
|
||||
assert "nanoleaf_per_panel" not in d.to_dict()
|
||||
assert d.to_config().nanoleaf_per_panel is False
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user