Commit Graph

639 Commits

Author SHA1 Message Date
alexei.dolgolyov fdc9201660 fix(api): remove broken legacy /system/mqtt/settings route
The GET/PUT /api/v1/system/mqtt/settings handlers read cfg.mqtt.*, but the
single-broker MQTTConfig block was removed in the multi-broker refactor, so any
call raised AttributeError. Brokers are now first-class MQTTSource entities
managed via the mqtt.py router, and the frontend no longer calls this endpoint.
Remove the dead handlers, the _load_mqtt_settings helper, the now-unused
get_config import, and the orphaned MQTTSettings{Request,Response} schemas.
2026-06-04 20:46:24 +03:00
alexei.dolgolyov 5686ae5468 fix(security): remove active weak default API key from shipped config
default_config.yaml shipped api_keys.dev: "development-key-change-in-production"
uncommitted/active, while the surrounding comment claimed it had been removed.
On a non-loopback bind this is a publicly-known credential granting full LAN
access. Restore the documented secure default (empty api_keys -> loopback-only
anonymous, LAN rejected) and leave a commented example instead.
2026-06-04 20:46:13 +03:00
alexei.dolgolyov 1c1bbe2551 feat(android): foreground-app automation condition
Make the existing Application automation rule (foreground app -> activate
scene) work on the Android-TV build. A Kotlin ForegroundAppBridge reads the
foreground app via UsageStatsManager and lists launchable apps via LauncherApps;
PlatformDetector bridges it in (ahead of the Windows-only ctypes guard) so the
existing AutomationEngine / ApplicationRule / storage / deactivation modes are
unchanged. New /system/installed-apps + /system/info endpoints feed an app picker
that stores package names (vs process names on desktop); on Android the editor
hides the match-type selector since the foreground app is the only obtainable
signal. PACKAGE_USAGE_STATS is granted via an on-device button + a web-UI banner
(no blanket prompt at capture start); detection degrades gracefully until granted.

Zero new Python/Gradle deps (UsageStatsManager + LauncherApps are in-platform;
matching only string-compares the package name, so no QUERY_ALL_PACKAGES).
assembleDebug + 1897 pytest + ruff + tsc + npm build all green; independent final
review (0 blockers) + security review (no critical issues).
2026-06-02 14:57:29 +03:00
alexei.dolgolyov 4bf3fe65db feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine)
Add on-device webcam capture to the experimental Android-TV build. Desktop
captures webcams via OpenCV (no Chaquopy/Android wheel); this adds a push-based
AndroidCameraEngine that plugs into the same selection path desktop uses
(capture template engine_type="android_camera" + display_index, HAS_OWN_DISPLAYS).

A Kotlin CameraBridge (Camera2) enumerates cameras and opens them on demand —
only while a capture source is active, driven Python->Kotlin via a guarded jclass
singleton (BleBridge pattern) — converts each frame YUV_420_888->RGB, and pushes
RGB bytes into a module-level queue mirroring mediaprojection_engine.py. Cameras
surface as selectable displays like the desktop OpenCV engine; the data-driven
capture-template UI is unchanged. No new Python deps; no new Gradle deps
(Camera2 is in-platform).

Engine: ENGINE_PRIORITY=0 (never auto-selected over MediaProjection=100; explicit
engine_type only). Single-camera ownership is serialized with a lock + ref-count
(same-camera streams attach, different-camera refused, last release stops),
mirroring the desktop CameraEngine guard.

Permission: CAMERA requested at capture-start, gated on FEATURE_CAMERA_ANY so
camera-less TV boxes never prompt; graceful degradation when denied. The service
is promoted with the camera FGS type (+ FOREGROUND_SERVICE_CAMERA) only when
CAMERA is already granted, so backgrounded capture keeps working without risking
a failed startForeground on camera-less boxes (camera can't ride the
MediaProjection token the way audio playback capture does).

Reviewed via multi-agent adversarial pass (13 findings -> 4 fixed: device leak on
session-failure, multi-stream collision, camera FGS type, i18n key; 9 refuted).

Tests: 18 new desktop-CI tests (no device needed); full suite 1883 passed.
Verified: assembleDebug BUILD SUCCESSFUL, ruff clean.

Docs: ANDROID-REVIEW/android-webcam-capture-plan.md (design), updated
android-missing-functionality.md + README feature table + en/ru/zh locales.
2026-06-02 13:36:23 +03:00
alexei.dolgolyov 0be3f833df feat(android): on-device OS notification capture (NotificationListenerService)
Add an Android backend to os_notification_listener.py so notifications on the
experimental Android-TV build drive the existing NotificationColorStripSource
LED effects (flash/pulse/sweep, per-app colors + sounds) at app-name parity
with the Windows/Linux backends.

A Kotlin NotificationListenerService forwards the posting app's display label
across the Chaquopy JNI boundary into a new push-based _AndroidBackend +
module-level push_notification() receiver; the existing color-strip pipeline,
per-app colors/filters, and history endpoint are reused unchanged.

- Python: _AndroidBackend (probed first), push_notification() receiver,
  _LinuxBackend.probe() hardened with is_linux() to exclude Android (which
  also reports platform.system() == "Linux").
- Android: LedGrabNotificationListener NLS — serial single-thread executor,
  full crash isolation around Python.getInstance(), label-only forwarding
  (never notification title/body), ongoing/group-summary/self-package noise
  filtering. Manifest service exported + gated by
  BIND_NOTIFICATION_LISTENER_SERVICE (no new uses-permission).
- UX: prompt-once notification-access + manual "Grant notification access"
  button wired into the D-pad focus chain (computed from visible controls);
  en/ru/zh strings.
- Tests: 11 isolated unit tests — module-global + tmp_path history isolation,
  push routing contract, callback-exception swallowing, None app-name, and a
  desktop-regression lock on backend selection order.
- Docs: README OS-support Android column (notification + audio cells),
  ANDROID-REVIEW status flipped to Implemented.

Zero new Python deps; no build.gradle.kts / Chaquopy pip changes.
2026-06-02 11:47:13 +03:00
alexei.dolgolyov fd62db1720 feat(audio): Android on-device system playback capture
Enable audio-reactive lighting on the Android-TV build. A push-based
AndroidAudioEngine captures system playback audio via AudioPlaybackCapture
(API 29+), reusing the existing MediaProjection token, and feeds PCM into
the unchanged AudioAnalyzer pipeline. No new Python deps; no Chaquopy/pip
changes (numpy already bundled).

- Python: android_audio_engine.py — module-level queue + configure/
  push_samples/shutdown mirroring mediaprojection_engine; AndroidAudioEngine
  (priority 100) registered behind a guarded import. push_samples copies and
  defensively trims/clamps each block so the analyzer can't crash on
  variable-length or non-frame-divisible PCM.
- Kotlin: AudioCapture.kt — AudioRecord + AudioPlaybackCaptureConfiguration,
  fixed chunk-size block framing, little-endian float32, mic fallback;
  reads back the actual negotiated channel/sample rate. PythonBridge gains
  configureAudio/pushAudio/shutdownAudio with a cached module handle.
- Wiring: CaptureService starts/stops AudioCapture in the MediaProjection
  path (gated on API>=29 + RECORD_AUDIO + live projection); MainActivity
  requests RECORD_AUDIO; manifest declares it. Degrades gracefully when
  denied; root path stays audio-less by design.
- Tests: 13 desktop-CI tests incl. an over-length/non-divisible regression
  guard that exercises the full read_chunk -> AudioAnalyzer.analyze path.
2026-06-02 03:28:22 +03:00
alexei.dolgolyov 669ae20824 feat(value-sources): optional normalization for magnitude sources
Add an opt-out `normalize` flag to the four magnitude value sources
(ha_entity, http, system_metrics, game_event). get_value() stays in
[0,1] for every source (the normalized scalar-bus invariant), so all
existing consumers — brightness sinks, gradient_map, template `name`
bindings, and color-strip bindable floats — are unaffected.

- normalize=False is a clamp-passthrough: skip the min/max rescale and
  clamp the raw reading into [0,1] (for sources already reporting a
  0..1 fraction). The un-normalized magnitude stays available via
  get_raw_value() / template raw[name] / automations.
- Add get_raw_value() + a raw channel to GameEventValueStream (it had
  none). game_event's flag is model/stream-level only (no CRUD schema).
- Finite-safe clamp01() util; harden the composite-layer brightness
  multiply (latent negative-wrap / >=1 skip) with it.
- Preview WebSocket: tolerate non-numeric raw, generalize raw-range.
- Frontend: settings-toggle slider per HA/system_metrics/http editor
  with min/max grey-out; toggle hidden for fixed-mapping percent
  metrics. en/ru/zh locale keys.
- Additive optional field (default True) — JSON round-trip, no migration.

Tests: store create/update round-trip, clamp-passthrough, live
normalize flip, game_event raw channel + build_stream forwarding,
and finite-safe clamp01.
2026-06-02 02:24:40 +03:00
alexei.dolgolyov 6de61b965e feat(value-sources): add sandboxed-Jinja template combinator
A new `template` value source evaluates a hardened, sandboxed Jinja
expression over the live values of other value sources — the system's
first float combinator.

Backend:
- Shared engine (utils/template_expr.py): ImmutableSandboxedEnvironment with
  filters/tests and auto-injected globals stripped; only min/max/abs/round/
  clamp exposed; rejects **, string/collection-literal repetition, attribute
  access and non-global calls; NaN/inf-safe result coercion.
- TemplateValueSource model + TemplateValueStream runtime: compile-once,
  primitives-only eval context, raw[name] exposure, eval_interval throttle,
  ref-counted input acquire/release, rename-safe hot-update.
- Validation: unbound-variable + reserved-name rejection, reference
  cycle/depth guards (depth-only at create, full cycle at update), runtime
  acquire() depth backstop, and delete referential-integrity.
- API: Create/Update/Response schemas + discriminated unions, _RESPONSE_MAP,
  and an advisory POST /value-sources/validate-template endpoint.
- Demo seed: a static source plus a template combinator example.

Frontend:
- Editor modal section: repeatable inputs list (EntitySelect rows), a
  zero-dependency Jinja syntax highlighter, a hints/reference panel, and a
  debounced live validator that gates Save (stale-response-safe).
- Graph editor: read-only template node with one edge per input.
- i18n (en/ru/zh), icon, and card rendering.

Tests: engine, stream, factory/cycle, validate endpoint, and demo seed.
2026-06-01 18:53:56 +03:00
alexei.dolgolyov 12b40e6071 docs: actualize README and API reference, embed screenshots
README: add free & open-source (MIT) framing; add a Platforms table (Windows/Linux/macOS/Docker supported, Android-TV build marked experimental, scrcpy phone-capture retained); expand the LED device list to ~20 protocols; correct storage to SQLite, the auth model, and resource names (Output Targets, Automations); add a prebuilt-download section; remove the Architecture and Acknowledgments sections; embed dashboard, channels, live-preview, and device-picker screenshots.

docs/API.md: full rewrite from the live route modules — all 253 endpoints across 34 modules, grouped by resource with REST + WebSocket tables, accurate auth model and WS handshake, worked examples, and sensitive-endpoint markers. Replaces the stale v0.1.0 stub.

server/CLAUDE.md: storage is now SQLite (BaseSqliteStore / ledgrab.db / LEDGRAB_DATA_DIR / data_migrations.py); fix the auth description (loopback anonymous, LAN rejected with 401 — not all endpoints open); router registration happens in api/__init__.py.
2026-05-29 14:35:45 +03:00
alexei.dolgolyov 498854f04d refactor(storage): gate clone() behind an opt-in allowlist; expand duplicate tests
Follow-up polish from the duplicate-subgraph review:

- BaseSqliteStore.clone() now requires an opt-in `_cloneable` flag (default
  off), so a new or secret-bearing store can never be cloned by accident —
  defence-in-depth on top of the endpoint's `_DUPLICABLE_KINDS` restriction.
  Only value-source and colour-strip stores are flagged.
- Fix `_check_name_unique` annotation (`str | None = None`).
- Add tests: `layers[].brightness_source_id` remap, the warnings safety net
  firing, the clone allowlist invariant, and clone() refusing a non-cloneable
  store.
2026-05-29 11:55:58 +03:00
alexei.dolgolyov 15cfb821d3 feat(graph): duplicate a selected subgraph server-side
A secret-safe equivalent of blueprint import: the graph editor's overflow menu
gains "Duplicate selection", which deep-clones the selected value and
colour-strip sources server-side (full config preserved, never crossing the
wire) and rewires references that point within the selection — shared
dependencies (devices, HA sources, …) stay shared.

- graph_schema.remap_refs: write-twin of extract_refs (same dot/list/bindable
  grammar) that rewrites only in-selection ids; 8 unit tests.
- BaseSqliteStore.clone(): faithful deep-copy clone (no schema round-trip, so no
  field is lost), prefix-preserving fresh id; reusable by any store.
- POST /api/v1/graph/duplicate: two-pass clone-then-rewire restricted to value /
  colour-strip sources (no inline secrets), with a safety net flagging any
  unremapped reference; 7 integration tests vs real stores.
- Frontend: duplicateSubgraph (+cache invalidation), graphDuplicateSelection
  (reload + reselect the new cluster), overflow-menu item, i18n (en/ru/zh).
2026-05-29 11:45:55 +03:00
alexei.dolgolyov 2e51f46dfd feat(graph): make the visual editor a full wiring control surface
Lets users wire the system end-to-end from the graph, and fixes the core
bug that made drag-to-wire silently fail.

- Fix drag-to-wire 422s across 5 entity kinds: updateConnection() now echoes
  the target's discriminator (source_type/stream_type/target_type) into the
  partial PUT, so value/colour-strip/audio/picture sources and output targets
  all wire correctly. New contract test (54 cases) in test_graph_wiring_contract.py.
- Re-wire composite layers / mapped zones from the graph (right-click a
  layer/zone source edge -> Re-wire). Whole-list write preserves every sibling
  layer/zone setting, with an optimistic-concurrency guard and undo.
- Secret-safe /graph topology: project entities to id/name/subtype + reference
  roots so the endpoint cannot leak webhook tokens or other credentials.
- Carry slot indices on list edges; node custom-icon + schema-drift refinements;
  rewire i18n keys (en/ru/zh); wiring-control roadmap (TODO.md).
2026-05-29 02:29:19 +03:00
alexei.dolgolyov 6aeda935f1 chore: release v0.8.1
Build Android APK / build-android (push) Failing after 11s
Build Release / create-release (push) Successful in 6s
Build Release / build-docker (push) Successful in 2m57s
Build Release / build-linux (push) Successful in 1m49s
Build Release / build-windows (push) Successful in 3m35s
Build Release / publish-release (push) Successful in 2s
2026-05-28 23:35:35 +03:00
alexei.dolgolyov a5effba553 feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers
Backend
- snapshot: GET /api/v1/snapshot aggregates targets, devices, sources,
  presets and system into one payload for the HA coordinator, collapsing
  the prior ~2N+M request fan-out; per-section ?include= gating.
- graph: GET /api/v1/graph{,/schema,/dependents} backed by a pure,
  unit-tested graph_schema engine — one authoritative connectable-field
  registry so the editor no longer hard-codes topology in two places.
- devices: thread mqtt_source_id through DeviceCreate/Update/Response and
  the routes for multi-broker MQTT; shared validate_mqtt_source_exists
  (_mqtt_validation.py) reused by device + output-target routes; stop
  update_device masking intentional 4xx as 500.
- shutdown: bound uvicorn graceful-shutdown via GRACEFUL_SHUTDOWN_TIMEOUT
  (shared by __main__, android_entry, demo) so a lingering events WebSocket
  can't strand LED targets or block process exit.
- access log: structured _access_log middleware attributing each request to
  its authenticated token label (never the secret); uvicorn access_log off.

Frontend
- graph editor: generic schema-driven port/edge rendering, layout and
  connection handling; service-worker refresh.
- device modals: MQTT broker EntitySelect for device_type=mqtt in add-device
  and settings, wired into load/save/validate/dirty-check/clone.
- i18n: en/ru/zh keys.

Tests: graph routes + schema, snapshot routes, access log, mqtt_source_id
device regressions, bounded-shutdown entrypoint. 1614 passed.
2026-05-28 22:51:04 +03:00
alexei.dolgolyov b83a72e63f chore: release v0.8.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 8s
Build Release / build-linux (push) Successful in 3m8s
Build Release / build-docker (push) Successful in 4m11s
Build Release / build-windows (push) Successful in 4m55s
Build Release / publish-release (push) Successful in 1s
2026-05-28 17:48:06 +03:00
alexei.dolgolyov 0d840adfca fix(ctypes): share wintypes.MSG with platform_detector to avoid argtype races
WindowsShutdownGuard was binding user32.GetMessageW.argtypes with
POINTER(_MSG) (project-local struct), while PlatformDetector's display-
power monitor binds it with POINTER(wintypes.MSG). argtypes is a
mutable global on the cached WinDLL handle, so whichever module
imported last won, and the other module's byref() then tripped
Python 3.13's strict argtype check with
"expected LP_MSG instance instead of pointer to _MSG".

The two structs are byte-identical (same field types in the same
order, just pt vs pt_x/pt_y naming) and we never touch the pt field,
so aliasing _MSG to wintypes.MSG eliminates the conflict — both
modules now bind the same POINTER class, the writes become idempotent,
and the full test suite passes regardless of import order.

CI runs on Linux so this never fired in release builds, but it broke
the local Windows test run.
2026-05-28 17:36:19 +03:00
alexei.dolgolyov 1f959932c1 fix(notification): allow clearing the sound on per-app overrides and main row
Previously, both the per-app override sound dropdown and the main
notification sound row only attached an EntitySelect when at least
one sound asset was registered — and never with allowNone. That left
users with no way to pick "no sound" once an entry existed, and made
the override dropdown silently inert before any assets were added.

Always construct the EntitySelect (so an empty assets list still
renders a usable, searchable input) and pass allowNone with the
localized none label so "no sound" is a first-class choice in both
the override list and the main row.
2026-05-28 17:28:34 +03:00
alexei.dolgolyov 66b85b0175 fix(css-editor): persist notification_sound + notification_volume
The CSS editor modal collected every other notification field on
save but silently dropped notification_sound and notification_volume,
so toggling them in the modal had no effect on the saved strip.
Include both in the save payload alongside the existing notification
fields.
2026-05-28 17:26:44 +03:00
alexei.dolgolyov 3645216669 feat(icons): spectrum aperture icon set + dedicated tray variant
Regenerate the LedGrab icon family from a single Pillow script
(build/generate_icon.py): a rounded-square aperture traced by a
continuous RGB color-wheel stroke over a vignette canvas with a soft
chromatic bloom. 4x supersampled then downsampled per output for
crispness. Outputs 192/512 standard, 512 maskable (safe-area padded
for PWA round-crops), and a new 256 transparent-background tray
variant so the taskbar icon reads cleanly against light themes
instead of showing a dark tile.

icon.ico now embeds 16/24/32/48/64/128/256 frames sourced from the
transparent tray master, fixing the dark-square halo around the
file/taskbar icon on light Windows themes.

__main__ picks icon-tray.png for the tray and falls back to
icon-192.png when the tray asset isn't present (older bundles /
forks).
2026-05-28 17:26:18 +03:00
alexei.dolgolyov 85da2e538d feat(backup): bundle assets in ZIP + partial-write hardening + restart log
Auto-backups now produce a ZIP containing ledgrab.db plus every file
in the assets dir under assets/ — matching the manual
GET /api/v1/system/backup format, so restore accepts either output
interchangeably. Legacy .db backups remain listable, restorable, and
prunable; both extensions count toward max_backups.

Writes stage to <name>.partial then os.replace into place — a crash
mid-ZIP never leaves a half-written backup that masquerades as valid.
Stale .partials from prior crashes are swept on the next run.
Symlinks inside the assets dir are skipped so a hostile link can't
slurp a target outside the dir into every backup. Backups larger than
500 MB log a warning so operators notice unbounded asset growth before
disk fills up.

restart.py: redirect the spawned restart script's stdout/stderr to
restart.log and bail out early if the script is missing — silent
failures (PowerShell off PATH, restart.ps1 erroring) used to vanish
into a detached child with no diagnostic trail.

Tests cover happy path, asset bytes round-trip, partial cleanup,
None/missing assets_dir, failure rollback, stale-partial sweep,
symlink rejection, mixed legacy+new listing, and cross-format prune.
2026-05-28 17:25:55 +03:00
alexei.dolgolyov e4d24a02da fix(ctypes): pin LPMSG across MSG-pump prototypes for Python 3.13
Python 3.13's ctypes tightened argtypes checks and now rejects
mismatched POINTER(MSG) cache entries — each call to POINTER(MSG)
can return a class identity that doesn't match what byref() of an
instance produces, raising "expected LP_MSG instance instead of
pointer to _MSG" inside GetMessageW/TranslateMessage/DispatchMessageW.

Capture POINTER(MSG) once into LPMSG and reuse the same class object
across all three prototypes in both the WindowsShutdownGuard pump and
the PlatformDetector display-power monitor. Restores the 4 failing
win_shutdown tests.
2026-05-28 17:25:37 +03:00
alexei.dolgolyov bb3a316e35 refactor(frontend): shared API client + automations registry (audit M7, H8)
H8 — automations.ts rule-type registry
  Convert the two hand-rolled RuleType dispatch ladders into per-type
  registries (RULE_FIELD_RENDERERS + RULE_COLLECTORS) keyed by RuleType,
  joining the existing RULE_CHIP_RENDERERS. All three are typed
  Record<RuleType, ...> for compile-time exhaustiveness; an import-time
  _assertRuleHandlerCoverage() check logs loudly if any registry drifts
  from RULE_TYPE_KEYS — mirrors the backend's _RULE_HANDLERS shape, the
  one intentional divergence being that the frontend logs rather than
  throws (a thrown error at module import would brick the whole bundle,
  not just the editor).

M7 — shared API client + 35 file migrations
  New core/api-client.ts wrapping fetchWithAuth with typed apiGet /
  apiPost / apiPut / apiPatch / apiDelete. Auth, 401-relogin, retry,
  timeout, and the offline toast all stay owned by fetchWithAuth; the
  client just collapses the
  if (!resp.ok) { detail || HTTP <status> } ... resp.json()
  dance into one typed call. The detail unwrap is hardened to join
  FastAPI validation arrays instead of stringifying to [object Object].

  35 feature/core files migrated to it across many batches, reviewer-
  approved for behaviour parity in three passes covering the riskier
  divergences (bulk Promise.allSettled deletes, inline-error saves,
  array-detail joins, silent-failure GETs, immutable clones).

  9 files remain on fetchWithAuth — the big god-modules tied to the
  pending C8/C9/C10 splits (streams, settings, targets, dashboard,
  color-strips/index, graph-editor, assets, value-sources) plus
  pairing-flow which by design stays on raw fetch (branches on raw
  Response.status codes).

i18n — 14 new locale keys (en / ru / zh)
  Added save/load/delete error keys across automations, pattern,
  audio_processing, audio_template, templates, gradient, target,
  device namespaces, plus backfilled gradient.error.delete_failed into
  ru/zh. Scan confirms no hardcoded English errorMessage strings
  remain in the migrated diff.

AUDIT_REMAINING.md updated to reflect H6, H8, and M7 status.

Verified: tsc --noEmit clean + npm run build clean after every batch.
2026-05-28 14:58:08 +03:00
alexei.dolgolyov 49c35a2ea0 refactor(frontend): split types.ts into 18 per-entity files (audit H6)
Convert the 1140-LOC types.ts into a pure re-export barrel backed by
focused per-entity files under types/, joining the existing
bindable.ts. Every import { ... } from '../types.ts' resolves
unchanged; reviewer-confirmed all 102 type exports preserved.
2026-05-28 14:57:25 +03:00
alexei.dolgolyov ef1f9eade2 feat(android): production-readiness pass — security, perf, compat, UI/UX
Multi-axis lift to ship-quality after a full review:

Security
- ApiKeyManager: per-install random API key, persisted via SharedPreferences
  with synchronous first-write; threaded into uvicorn via the
  LEDGRAB_AUTH__API_KEYS env var; embedded in QR as a URL fragment (#k=)
  so it never appears in HTTP requests or server logs; frontend reads
  location.hash on first visit and strips it via history.replaceState
- Root.runAsRoot(argv: Array<String>) overload with POSIX shell-quoting to
  eliminate the shell-injection footgun (= excluded from unquoted-safe set)
- UsbSerialBridge: ContextCompat.RECEIVER_NOT_EXPORTED + intent.package
  check in the broadcast receiver for defence-in-depth across API levels
- Release builds refuse to silently fall back to debug keystore; require
  ANDROID_KEYSTORE_* env vars or explicit
  ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1
- Crash log retention capped at 10 entries
- Fatal-error stack trace hidden behind a toggle on the error screen

Performance
- ScreenCapture / RootScreenrecord reuse a single RGBA ByteArray per
  pipeline instead of allocating per frame — eliminates ~15 MB/s GC churn
  at 30 fps on low-end TV boxes
- Frame pacer switched from System.currentTimeMillis() + integer division
  (~30.3 fps drift) to SystemClock.elapsedRealtimeNanos with a catch-up
  accumulator
- ScreenCapture computes capture dimensions from source aspect ratio so
  non-16:9 displays don't get squashed
- RootScreenrecord input pump backs off 5 ms when MediaCodec is starved,
  ending a tight spin that burned a CPU core on decoder stalls
- QR cached by URL — onResume from background no longer rebuilds the
  560×560 bitmap each time
- ApiKey commit() pre-warmed off Main on app startup

Compatibility
- compileSdk / targetSdk bumped to 35 (Play Store requirement)
- armeabi-v7a build path added to build script + conditionally included
  in gradle splits when the matching wheel is present in android/wheels/
- Foreground service type declared as mediaProjection|specialUse with
  PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale; promotion via
  ServiceCompat.startForeground with the correct type per mode
- NetworkUtils picks Ethernet > Wi-Fi > VPN > cellular instead of just
  activeNetwork — fixes wrong-URL on TV boxes with both Ethernet + Wi-Fi
- enableOnBackInvokedCallback=true for Android 15 predictive-back
- Splash screen API via androidx.core:core-splashscreen — hides Chaquopy
  stdlib unpack delay on cold first launch

UI / UX
- All previously hardcoded English strings (root prompt, permission
  denial, fatal-error screen, notification text) now localised across
  en/ru/zh
- Monochrome notification icon (was a colored launcher → gray blob in
  status bar)
- 320×180 TV banner (was the square launcher → squashed on Leanback row)
- ViewStub-based running panel (deferred inflation)
- ObjectAnimator pulse on the Running status dot for liveness feedback
- "Starting…" button state while root is being probed
- Autostart checkbox hidden entirely on unrooted devices
- "No network" status when getLocalIpAddress returns null
- QR fallback hint text
- Animator cancelled in onStop to avoid leaking view hierarchy

Lifecycle hardening (from review)
- RootScreenrecord: processLock serialises EOF respawn vs concurrent
  stop() to prevent orphaned screenrecord processes
- CaptureService.restartRootPipeline: publish-before-start under
  @Synchronized to close the orphan window during watchdog restarts
- ScreenCapture.MediaProjection.Callback.onStop just flips
  running=false instead of calling stop() (which self-joined
  captureThread and hung 500 ms)
- updateUI early-returns when lateinit not initialised (fatal-error path)
- Watchdog give-up bound fixed (>= instead of >, was allowing 4 attempts)

server/android_entry.py accepts an optional api_key, sets
LEDGRAB_AUTH__API_KEYS={"android":<key>} as JSON before any LedGrab
import, logs a clear error if pydantic-settings parsing doesn't land
the value back in config (defensive guard against future settings
behaviour drift).

server/static/js/app.ts: bootstrap reads #k= from location.hash,
persists to localStorage, then strips via history.replaceState.

Two independent code-review passes; 147 relevant server tests still
pass; TypeScript and ruff clean.
2026-05-26 12:52:14 +03:00
alexei.dolgolyov 8bdcc17799 chore: release v0.7.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 11s
Build Release / build-linux (push) Successful in 2m54s
Build Release / build-docker (push) Successful in 3m50s
Build Release / build-windows (push) Successful in 4m36s
2026-05-26 00:35:38 +03:00
alexei.dolgolyov f591e258f7 fix(storage/database): reopen connection on lifespan restart
Database opened its sqlite3 connection eagerly in __init__ and closed it
in close(); the lifespan called close() on shutdown. In production this
is fine — the lifespan runs once per process. Under pytest the module-
level ``db`` singleton survives across every TestClient session, so the
second test file's lifespan startup hit
``sqlite3.ProgrammingError: Cannot operate on a closed database`` at
fixture-setup time (AutoBackupEngine.__init__ → db.get_setting("…")
was the first reader). 65 spurious "errors" on a full Windows pytest run.

- Database: extract _open() from __init__, add ensure_open() that
  reopens iff _conn is None, and have close() null _conn after the
  TRUNCATE checkpoint so re-close is idempotent.
- main.py lifespan startup: call db.ensure_open() before any setting
  read, so subsequent TestClient sessions get a live connection.
- tests/storage/test_database_reopen.py: pin the four invariants —
  close→ensure_open round-trips data, ensure_open is a no-op when
  open, close is idempotent, and using the DB after close without
  ensure_open raises (callers must opt in).

Full backend suite: 1551 pass / 1 skip / 0 errors. Ruff clean.
2026-05-26 00:26:36 +03:00
alexei.dolgolyov f6486f9b34 perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings
- dashboard: only destroy/recreate FPS charts for added/removed/detached
  targets; skip the history fetch when local samples already exist.
  Drops sync-clock `is_running` from the structure signature so toggles
  don't trigger a full rebuild; route clock/automation refresh through
  the in-place path.
- perf-charts: cache SVG skeleton per spark host and mutate node
  attributes instead of rewriting `innerHTML` every poll. Memoize
  patches/devices rendering by content signature so unchanged ticks
  no longer restart CSS animations. Skip render for env-hidden cards.
- perf-charts: switch `/system/performance` poll to `fetchWithAuth`,
  re-read `dashboardPollInterval` per tooltip move, and route the
  remaining hardcoded English strings ("no captures", "{n} total",
  "{rate} skipped/s", tooltip age, metric labels) through `t()`.
- locales: add `perf.no_captures`, `perf.captures_count`,
  `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`,
  `perf.tip.now`, `perf.tip.ago` in en/ru/zh.
2026-05-26 00:12:29 +03:00
alexei.dolgolyov 003517247f refactor(types): migrate (window as any) statics to typed window globals
59 sites across 19 feature modules switched from
`(window as any).foo` to the typed `window.foo` form against
global-types.d.ts. The 7 remaining sites use dynamic string indexing
(`window[fnName]`) where a typed access is impossible — those keep
the narrow cast and are documented as the legitimate exception in
the typedef file's header.

global-types.d.ts grows entries for `loadIntegrations`,
`loadUpdateSettings`, `loadUpdateStatus`, `initUpdateSettingsPanel`,
`onTestDisplaySelected`, `openSettingsModal`, `renderAboutPanel`,
`switchSettingsTab`. The `applyAccentColor` signature is widened to
accept the (accent, persist) call shape observed at the appearance
preset call site so tsc validates the real contract.
2026-05-23 01:22:29 +03:00
alexei.dolgolyov 888f8fd16e refactor(types): PEP-604 union sweep + UP007/UP045 enforcement
ruff --select UP007,UP045 --fix converted ~1760 sites across the
backend: `Optional[T]` → `T | None`, `Union[X, Y]` → `X | Y`. The
remaining module-level alias targets that ruff conservatively skips
(BindableFloatInput, ColorList, DeviceConfig) were converted by hand
earlier in the pass. black -formatted the result so the wider unions
fit cleanly under the 100-char line budget.

pyproject.toml now sets [tool.ruff.lint] extend-select = ["UP007",
"UP045"] so future legacy imports fire CI on every push. The
pre-commit ruff hook was bumped from v0.8.0 -> v0.15.12 to recognise
UP045 (split off from UP007 in v0.13).
2026-05-23 01:21:44 +03:00
alexei.dolgolyov ea7ee88490 refactor(api/auth): narrow WS exception catches + observability log
The 11 except Exception sites around websocket.send_json and
websocket.close are now except _WS_SEND_BENIGN_EXC — a narrow tuple of
WebSocketDisconnect, RuntimeError, ConnectionError, OSError. Real
programming errors (AttributeError, TypeError) no longer silently
disappear inside the handshake path. The receive_text branch grows a
narrow `(RuntimeError, ConnectionError, OSError)` case plus a final
`except Exception: logger.exception(...)` catch-all so genuinely
unexpected error shapes are recorded with a stack trace instead of
being swallowed.
2026-05-23 01:14:43 +03:00
alexei.dolgolyov d38021f061 refactor(processing): hot-path magic numbers -> named module constants
processed_stream lifts the 30-iteration filter recheck cadence to
_FILTER_RECHECK_EVERY_N_FRAMES with a comment explaining the 30 fps
trade-off. wled_target_processor lifts SKIP_REPOLL, the diagnostics
interval, and the CSPT recheck cadence to module-level
_SKIP_REPOLL_SLEEP_SECONDS, _DIAGNOSTICS_REPORT_INTERVAL_SECONDS, and
_CSPT_RECHECK_EVERY_N_ITERATIONS. Tests can monkeypatch them now.
2026-05-23 01:14:31 +03:00
alexei.dolgolyov 507e1385a6 feat(ui/icon-select): defence-in-depth XSS sanitiser on icon channel
Every IconSelect caller was audited: each builds item.icon from a
constant ICON_* literal, a lookup-table getter, or
renderDeviceIcon(stored_id) — none of which embed user input today.
The new sanitiseIcon() helper is the belt-and-braces guard for a
future caller that forgets the trusted-SVG contract: reject icon
strings containing <script>/<iframe>/<embed>/<object>/javascript:/on*=
markers, warn to the console, and fall back to empty so the cell still
renders the (escaped) label + desc.
2026-05-23 01:13:55 +03:00
alexei.dolgolyov 907bdaf043 test(url-scheme): WLED route-level integration + IPv6 regression
TestWLEDSchemeInference in test_devices_routes covers the POST/PUT
create-and-update flow with a stubbed WLED provider so the
infer_http_scheme integration hop has end-to-end coverage instead of
just the unit tests.

test_url_scheme grows public IPv6 (Cloudflare / Google / Quad9 DNS),
bracketed-form, and ULA cases. Adds an explicit pin for the Python
ipaddress documentation-prefix quirk (2001:db8::/32 is is_private,
so it routes to http:// even though some audits colloquially call it
"public").
2026-05-23 01:13:44 +03:00
alexei.dolgolyov 0dd8d430b9 fix(devices): preserve existing URL on PATCH-without-url
When a PATCH omits `url` (rename / icon-only edit), normalized_url
arrived at the processor as None and the manager kept whatever it had
cached — or refused to re-sync if it had nothing. Fall back to
existing.url so the processor is always told the current address.
Surfaced by the production-review backlog.
2026-05-23 01:13:13 +03:00
alexei.dolgolyov fd46c51dba docs: TODO + CLAUDE.md notes + locale keys for new features
TODO.md grows the device-support follow-up roadmap. CLAUDE.md trims a
stale section. en/ru/zh locales add the strings used by the new
HTTP-endpoint editor, MiniSelect labels, automations expansion, and
value-source kinds. Ru/zh parity for the older keys is tracked
separately in REVIEW_TODO.md.
2026-05-23 00:50:31 +03:00
alexei.dolgolyov ddae5719cf chore(frontend-infra): inbound-event allowlist + storage/state touch-ups
events-ws gains an inbound-event allowlist matching the new server-side
allowlist; test_events_ws_parity pins the two lists in sync.
state + storage modules and the streams / integrations /
z2m-light-targets / streams-*-templates editors absorb the
closeIfPristine guard alongside small UX fixes. css-editor template
picks up the new MiniSelect markup for the filter-kind picker.
2026-05-23 00:50:15 +03:00
alexei.dolgolyov 898912f8b1 chore(backend): MQTT/WLED/devices/capture/utils + api routes hardening
Bundle the remaining backend touch-ups that the production review
landed individually as small surgical edits across many modules:
- MQTT runtime: fire-and-forget task tracking + drain resilience.
- mqtt_source + store + storage/color_strip_source: secret_box
  encryption for credentials with auto-migration of plaintext fields.
- devices/discovery_watcher: task tracking on watcher start/stop.
- devices/wled_client + wled_provider: URL scheme inference helper
  applied at the create/update boundary so bare hostnames stay valid.
- core/capture/screen_capture: hardened error paths.
- core/processing (mapped/processed/processor_manager/video/wled_target):
  smaller follow-throughs from the registry refactor that landed
  earlier on the branch.
- utils/safe_source + utils/file_ops + utils/__init__: shared URL +
  IP classification helpers + larger streaming upload size caps.
- api/auth: WebSocket Origin allow-list + /docs auth-gate.
- api/dependencies: register the new HTTP-endpoint store.
- api/routes (assets, backup, webhooks): streaming-upload caps +
  asyncio.gather return_exceptions on broadcast loops.
- tests/test_api + tests/e2e/test_backup_flow: cover the new caps and
  the Origin allow-list.
2026-05-23 00:50:01 +03:00
alexei.dolgolyov 45d12b2811 feat(update-service): SSRF-validated redirects + restart hardening
update_service grows explicit URL validation on the redirect chain so a
hostile mirror can't bounce the updater to a private IP. restart.ps1
gets stricter argument handling and clearer log lines.
default_config.yaml exposes the new toggles. test_system_routes pins
the new behaviour.
2026-05-23 00:49:18 +03:00
alexei.dolgolyov 826e680f37 refactor(color-strip): rename static -> single + frontend follow-through
The "static" source kind always rendered a SINGLE color and the name
confused new code paths. Rename the module + kind to "single". Storage
keeps backward-compatible serialisation. Frontend color-strip cards /
gradient / index / test modules and the affected tests follow the new
name.
2026-05-23 00:49:00 +03:00
alexei.dolgolyov 737fd72b73 feat(value-sources): extend storage + schema + UI alongside new kinds
Storage model + Pydantic schema + route gain fields for the value-source
kinds introduced by the per-type factory refactor. Frontend editor adds
inputs for them and the modal template grows new field rows.
2026-05-23 00:48:48 +03:00
alexei.dolgolyov 3fe66d80cb feat(automations): expand automation rules + UI + engine coverage
Storage model + Pydantic schema + route surface gain the new rule
shapes the engine already supports. Frontend automations editor grows
the matching inputs. New core/test_automation_engine.py pins the
dispatch table rules behind ~285 lines of unit coverage.
2026-05-23 00:48:19 +03:00
alexei.dolgolyov f03cb303c3 feat(modal): closeIfPristine save-guard + per-editor adoption
Modal gains closeIfPristine(entityId): when editing an existing entity
and no tracked field has changed, the helper force-closes the modal
silently and returns true so the caller can skip the PUT and the
misleading "updated" toast. Each editor's save handler now early-returns
on the no-op edit path: advanced-calibration, assets,
audio-processing-templates, audio-sources, calibration, devices,
game-integration, ha-light-targets, home-assistant-sources,
mqtt-sources, pattern-templates, scene-presets, sync-clocks, targets,
weather-sources.
2026-05-23 00:48:00 +03:00
alexei.dolgolyov 9ff83bd6ca feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals
MiniSelect replaces the forbidden plain <select> in editors where the
option list isn't large enough to justify the full IconSelect grid. It
shares the IconSelect look-and-feel (chip + dropdown panel, keyboard
nav, search). IconSelect grows an explicit HTML-escape for item labels
and keeps `item.icon` documented as a trusted-SVG sink for callers
that build the icon string from constants. global-types.d.ts gives
existing window.* accesses real types so feature modules can stop
falling back to `(window as any)`. The modal.css additions style the
two selectors and the new dropdown panels.
2026-05-23 00:47:45 +03:00
alexei.dolgolyov d6cc80074d feat(http-endpoints): introduce HTTP endpoint output target stack
New output kind that POSTs the current strip frame to a user-configured
HTTP endpoint, alongside WLED / MQTT / Hue. Stack mirrors the existing
output-target shape end-to-end: storage model + store, FastAPI router +
Pydantic schemas, JS feature module + modal template, router wiring in
api/__init__.py and the modal include in index.html. Tests cover both
the routes and the store.
2026-05-23 00:47:31 +03:00
alexei.dolgolyov 2f15fbb752 refactor(output-targets): registry + coverage assertion for response builders
``_target_to_response`` in ``api/routes/output_targets.py`` used to be
an isinstance ladder over the three OutputTarget subclasses with a
silent fallback that fabricated a ``LedOutputTargetResponse`` for
unknown types (audit finding H3). The fallback masked exactly the
kind of bug we hit on the CSS side in Phase 1.1: a new target subclass
slipped past the ladder and got mis-shaped on the wire.

Replace the ladder with a ``_TARGET_RESPONSE_BUILDERS`` dict keyed by
the concrete subclass plus an import-time
``_assert_target_response_coverage()`` that requires the registry to
exactly match ``{WledOutputTarget, HALightOutputTarget,
Z2MLightOutputTarget}``. ``_target_to_response`` now raises
``RuntimeError`` instead of silently fabricating a LED response for an
unknown subclass — coverage is asserted at import so this branch is
unreachable in normal operation.

Tests: 5 new regression tests cover bijection between expected classes
and registered builders, callable shape, the rogue-target-raises
contract, and missing/extra entry rejection in the assertion. 24
existing output-target tests stay green; ruff clean.
2026-05-23 00:03:01 +03:00
alexei.dolgolyov c1aa2ebec5 fix(value-source): preserve store contract for game_event + error precedence
Two HIGH issues surfaced by review of 3b8f00e:

1. ``_build_game_event`` was newly succeeding where the old store
   raised ``ValueError("Invalid source type: game_event")``. The
   coverage-assertion-symmetry comment was honest about it being
   a path that didn't exist before, but silent broadening of the
   create contract is a real behaviour delta — any internal caller
   that previously caught the error would now succeed.

   Make ``_build_game_event`` raise NotImplementedError. The
   coverage assertion still passes (the entry exists), but the
   historical "you can't create game_event sources through the
   store" contract is preserved. game_event instances continue to
   be wired up by the game-integration setup path.

2. The new ``create_source`` ran ``_check_name_unique`` BEFORE
   ``build_source``. When both ``source_type`` is invalid AND
   ``name`` collides with an existing source, the old code raised
   ``"Invalid source type: …"`` first; the new code raised the
   name-collision error. Swap the order: build first (which
   validates source_type), then check name uniqueness, then
   persist. Bonus: a uuid is no longer minted for a source we end
   up rejecting on type.

New test pins the game_event NotImplementedError so a future
refactor doesn't accidentally re-open the create path.

38 value-source-store + factory tests stay green; ruff clean.
2026-05-23 00:00:30 +03:00
alexei.dolgolyov 3b8f00e3f9 refactor(value-source): per-type factories for create / update dispatch
ValueSourceStore.create_source used to be a ~260-line if/elif chain
over 14 source_type strings; update_source did the same dance again
with 14 isinstance branches (audit finding C7 store-side). Each
branch duplicated the common-fields scaffold and the per-type
defaulting + validation logic.

Lift each per-type create / update body into a free function in a
new ``storage.value_source_factories`` module:

  * ``CREATE_BUILDERS[source_type]`` — owns defaulting + per-type
    validation (HA needs ha_source_id + entity_id; gradient_map
    needs value_source_id; system_metrics validates against
    VALID_SYSTEM_METRICS; http rejects interval_s < 1; the two
    adaptive_* sub-modes route to the same AdaptiveValueSource
    class with different source_type discriminators).
  * ``UPDATE_APPLIERS[source_type]`` — mirrors the above on the
    update side; ``resolve_ref`` is applied to cross-entity
    references so empty-string clears keep working.
  * ``build_source(...)`` / ``apply_update(source, **kwargs)`` are
    the public entry points the store calls.
  * ``_assert_factory_coverage()`` runs at module import and
    requires BOTH registries to match storage's _VALUE_SOURCE_MAP
    exactly.

The store's ``create_source`` shrinks from ~260 lines to ~25;
``update_source`` from ~200 lines to ~40.

Tests: 14 new tests cover registry coverage in both directions
plus drift assertions, representative builder paths (static /
adaptive_time / adaptive_scene / ha_entity / http / unknown),
the AdaptiveValueSource dual-source-type discriminator, and
several applier paths including ``**_`` swallowing unknown kwargs
and HTTP zero-interval rejection. 47 existing value-source store
tests stay green; 769 storage / core / api tests in aggregate.
Ruff clean.
2026-05-22 23:56:10 +03:00
alexei.dolgolyov 05f73eedf9 refactor(types): extract bindable primitives into types/bindable.ts (H6 partial)
types.ts is 1159 lines of kitchen-sink discriminated-union definitions
(audit finding H6) shared across ~30 frontend modules. Splitting the
whole file in one pass would need careful per-group TypeScript wrangling
and verification across every entity shape; this commit lands the
first slice as a proof of pattern.

What changed
------------

* New ``static/js/types/bindable.ts`` owns ``BindableFloat`` /
  ``BindableColor`` plus their four accessor helpers
  (``bindableValue``, ``bindableSourceId``, ``bindableColor``,
  ``bindableColorSourceId``).
* ``static/js/types.ts`` keeps every interface and union shape that
  references those primitives, but the primitives themselves now come
  from the new file. Re-export keeps every existing ``import { ... }
  from '../types.ts'`` site working unchanged.

Why this slice first
--------------------

Bindable types and helpers are the most heavily-imported piece of
types.ts (~30 modules already use ``bindable*`` helpers) and they have
zero downstream dependencies — they don't reference any other type
group. That makes them the safest extraction and the cleanest
demonstration of the barrel-re-export pattern the remaining groups
(devices, sources, integrations, automations, templates, …) will
follow in a follow-up sprint.

Verification
------------

* ``npx tsc --noEmit`` clean (no compile errors anywhere in the
  frontend tree).
* ``npm run build`` clean (esbuild bundle and CSS bundle produced
  without warnings).

The remaining ~1130 lines of types.ts plus the C8/C9/C10 god-module
splits (value-sources.ts, streams.ts, graph-editor.ts) need a
dedicated frontend session with typescript-reviewer + manual UI
testing — deferring those rather than half-finishing them here.
2026-05-22 23:35:42 +03:00
alexei.dolgolyov 9f3f346543 refactor(value-source): MetricSpec registry for SystemMetricsValueStream
SystemMetricsValueStream used to dispatch on its ``self._metric`` string
across three independent if/elif chains (audit finding M5):

  * priming in ``start()`` (cpu_percent seed, initial network counter);
  * raw reading in ``_read_metric_psutil`` plus ``_read_metric_fallback``;
  * normalisation in ``_normalize`` (percent / min-max range / max-rate).

Adding a new metric meant editing all three chains plus the Android
fallback — and forgetting one branch made the metric silently return 0.

Lift each per-metric concern into a free function and register them as a
``MetricSpec(name, read_psutil, read_fallback, normalize, prime)`` in a
new ``core.processing.metric_readers`` module. Shared normalisers
(``_norm_percent`` / ``_norm_range`` / ``_norm_rate`` / ``_zero``) live
once. The stream's ``start()`` / ``_read_metric()`` / ``_normalize()``
collapse to a single registry lookup + delegation.

The stream still owns its mutable state (``_disk_path``,
``_sensor_label``, ``_gpu_unavailable``, ``_prev_net_bytes``,
``_prev_net_time``, etc.) — readers operate on the stream by
parameter, not by inheritance, so the kitchen-sink class shrinks by
~140 lines without losing the per-stream cadence bookkeeping. Each
spec function's docstring documents which fields it reads or mutates.

Tests: 16 new tests cover the 10-metric coverage set, callable shape
of every spec field, the three normaliser primitives' clamping +
divide-by-zero behaviour, prime-hook presence (only the three metrics
that need a baseline: cpu_load + network_rx + network_tx), and
fallback-path expectations (desktop-only sensors -> _zero, cpu/ram ->
real MetricsProvider).

754 existing core / storage / api tests stay green; ruff clean.
2026-05-22 23:29:33 +03:00
alexei.dolgolyov 98fb61d932 refactor(automations): rule dispatch via class-level handler table
AutomationEngine._evaluate_rule used to rebuild a 9-entry dispatch
dict on EVERY rule evaluation (audit finding H2). Unknown rule types
silently returned False — adding a new Rule subclass without an entry
just made it inert forever.

Refactor:

  * Per-rule-type bodies are now ``_handle_<kind>(self, rule, ctx)``
    methods on AutomationEngine.
  * A ``_RuleEvalContext`` frozen dataclass bundles all the
    cross-cutting state (running_procs, topmost_proc,
    topmost_fullscreen, fullscreen_procs, idle_seconds, display_state)
    so adding a new handler does not require widening
    ``_evaluate_rule``'s parameter list.
  * ``AutomationEngine._RULE_HANDLERS`` is bound once at module-import
    time after the class is defined.
  * ``_assert_rule_handler_coverage()`` runs at import: every Rule
    subclass imported by the module must have an entry, and entries
    keyed by an unknown class are also rejected.

Unknown-type fallback now logs a warning instead of silently returning
False, so a future Rule subclass missing from the registry surfaces in
operator logs rather than just behaving as if the automation were off.

The pure storage layer (storage/automation.py) is untouched — the
handler bodies stay on the engine where the cross-layer dependencies
(MQTT runtime, HA manager, HTTP endpoint store, webhook state) live.

Tests: 4 new tests cover the rule-type/handler bijection, callable
shape, missing-entry rejection, and unknown-class rejection. 44
existing automation engine tests stay green; ruff clean.
2026-05-22 23:07:07 +03:00