Compare commits

..

25 Commits

Author SHA1 Message Date
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 06273ba2bc chore(tooling): vex semantic-search config + REVIEW_TODO backlog
Add .vex.toml so `vex` is the project's primary code-search backend with
auto-update + semantic embeddings enabled. Ignore the .fastembed_cache/
directory that vex creates on first --semantic run. REVIEW_TODO.md
captures items flagged by the multi-agent production review that were
deliberately deferred (multi-day refactors, profile-first perf, and
design-sensitive security work).
2026-05-23 00:46:44 +03:00
alexei.dolgolyov 628c6b2f0d docs: capture architecture-audit remainder for follow-up sessions
10 commits in this branch landed the data-safety bugs (C2, C11), the
worst parallel-change problems (C1/C3/C4/C6/C7), and 5 of the 9 HIGH
audit findings. The remainder splits cleanly into four follow-up
sessions: a frontend sprint (C8/C9/C10/H6/H7/H8/M7-M11/L1), a Device
redesign (H4), a BaseTargetProcessor ABC (C5/H5/M1/M2/L2), and polish
(M3/M6/M12/L3-L5).

This file documents what's left, the recommended ordering, and the
three registry patterns already in the codebase that new contributors
should reach for instead of writing fresh if/elif chains.
2026-05-23 00:36:39 +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
alexei.dolgolyov 5fec8db901 refactor(capture): lift duplicated edge-to-LED kernels into shared module
PixelMapper and AdvancedPixelMapper in calibration.py used to carry
byte-for-byte copies of two ~80-line numpy kernels (audit finding M4):

  * the vectorised average-colour-per-LED path with its cumsum + take
    scratch-buffer dance; and
  * the per-LED fallback loop for median / dominant colour modes.

Lift both into a new ``core.capture.edge_interpolation`` module exposing
``average_edge_to_leds(edge_pixels, edge_name, led_count, cache,
cache_key)`` and ``fallback_edge_to_leds(edge_pixels, edge_name,
led_count, calc_color)``. The cache parameter is the caller-owned dict
(``self._edge_cache``) so allocations still happen once per
(edge_len, led_count) signature — the difference is that the
boundary-builder, the buffer set, and the inner numpy ops live in
exactly one place.

PixelMapper keys its cache by edge name (``"top"`` / ``"left"`` etc.);
AdvancedPixelMapper keys by line-index int (same dict, no collision).
Both mappers' ``_map_edge_average`` / ``_map_edge_fallback`` shrink to
single delegating lines.

Tests: 9 new kernel-level tests cover uint8 dtype + shape, the cache
reuse / rebuild contract, independent cache keying, a gradient input
producing a monotonic output, the calc_color callable contract for the
fallback path, and segment-position tracking for both axes. 30
existing calibration tests stay green; ruff clean.
2026-05-22 23:03:44 +03:00
alexei.dolgolyov 97dae2cd62 refactor(processing): replace inline effect dispatch with @_effect_renderer registry
EffectColorStripStream._animate_loop used to rebuild a 12-entry dict
``renderers = {"fire": self._render_fire, ...}`` on every frame, then
look up ``renderers.get(self._effect_type, self._render_fire)``. Two
audit smells (H1) at once: per-frame dict-rebuild churn and a silent
fallback to fire whenever ``self._effect_type`` was a typo or any
``_render_*`` method got renamed without updating the dict.

Fix:

  * ``@_effect_renderer("fire")`` stamps an attribute on the unbound
    method.
  * ``@_collect_effect_renderers`` (applied to the class) walks
    members at class-creation, gathers the marked ones into
    ``cls._RENDERERS``, and raises ``RuntimeError`` on duplicate
    registration.

The loop now reads ``type(self)._RENDERERS`` once and calls the
unbound method with explicit ``self``. An unknown ``_effect_type``
logs a warning and skips the frame (sleep one frame_time) instead of
silently rendering fire — louder failure mode without crashing the
animation thread.

Tests: 5 new tests cover the 12-effect coverage set, callable shape,
class-level (not per-instance) dict identity, duplicate-name
rejection, and the marker stamp contract.

343 existing processing / storage / API tests stay green; ruff clean.
2026-05-22 23:00:00 +03:00
alexei.dolgolyov 29bdacf69a refactor(processing): dedupe HA/Z2M _swap_color_source via shared helper
HALightTargetProcessor and Z2MLightTargetProcessor used to carry
character-for-character identical _swap_color_source method bodies
(audit finding C5) — only the log prefix differed. Extract the body
into a free function ``swap_color_source(processor, new_kind,
new_color_vs_id, *, log_label)`` in a new ``light_target_helpers``
module. Each processor's _swap_color_source now delegates to the helper
and then clears its per-entity history (``_previous_colors`` /
``_previous_on``) — that bit stays on the processor because it's per-
target state, not colour-source state.

Scope deliberately narrower than the full BaseLightTargetProcessor ABC
the audit gestured at: the 76 read sites for the per-processor colour
state across the two files made a full state-composition refactor too
risky for the live LED control loop. The free-function helper is the
minimum-blast-radius way to delete the duplication while leaving WLED
(which has no value-stream-vs-CSS dispatch) untouched.

The helper standardises both warning messages on HA's original wording
("failed to acquire color VS stream" / "failed to re-acquire CSS
stream") so existing log alerts/grep patterns keep working.

A LightTargetSwapState Protocol under TYPE_CHECKING documents the
expected processor surface; no runtime enforcement (acceptable trade-
off vs a 76-site touchpoint).

Tests: 7 new tests cover the release+acquire ordering, the not-running
no-op path, the manager-error-swallowing behaviour, the empty-id
short-circuit, and the missing-manager (TargetContext(None, None))
fallback. 354 existing storage + API + e2e + processing tests stay
green; ruff clean.
2026-05-22 22:54:14 +03:00
alexei.dolgolyov 563cbac88c refactor(storage,processing): kind registries + versioned data migrations
Two CRITICAL data-safety bugs from the architecture audit and the two
worst parallel-change problems are fixed in one coherent pass.

Audit findings addressed:

- C2  silent CSS response fallback. The previous _RESPONSE_MAP fell
      through to a fabricated PictureCSSResponse whenever a source
      class lacked an entry; in particular game_event sources were
      silently mis-shaped. Now: GameEventCSSResponse/Create/Update
      schemas exist, _RESPONSE_MAP is re-keyed by source_type string,
      an import-time _assert_response_map_coverage() requires symmetric
      agreement with storage._SOURCE_TYPE_MAP, and the runtime path
      raises instead of fabricating a response.

- C11 string-replace JSON migration. ColorStripStore used
      blob.replace('"source_type": "static"', '"source_type":
      "single_color"') which can corrupt unrelated substrings (e.g.
      an animation type named "static_wave") and provides no audit,
      no transaction, no idempotency. Replaced with
      storage.data_migrations.MigrationRunner backed by a
      data_migrations audit table. Each migration runs inside one
      db.transaction() that covers the applied-check, the apply(),
      and the audit-INSERT — partial failures roll back atomically.
      StaticToSingleColorMigration parses each row with json.loads
      and mutates only the source_type field. Frozen-write databases
      skip with a warning.

- C3+C4 color-strip stream dispatch. The 7-branch elif in
      ColorStripStreamManager.acquire() and the duplicate one in
      ws_stream._create_stream() now share a single STREAM_BUILDERS
      registry in core.processing.color_strip_kinds, keyed by
      source.source_type. Both call sites populate a StreamDeps bag
      and delegate to build_stream(). _assert_stream_kind_coverage()
      asserts at import that STREAM_BUILDERS plus SHARABLE_KINDS
      partitions storage._SOURCE_TYPE_MAP. ws_stream's preview path
      wraps each FastAPI-DI getter in _safe() so non-audio previews
      no longer crash when audio/CSPT stores are not wired.

- C6+C7 value stream dispatch. The 14-branch isinstance ladder in
      ValueStreamManager._create_stream and its silent
      StaticValueStream(value=1.0) fallback are replaced by
      core.processing.value_kinds.STREAM_BUILDERS, keyed by
      source_type string (so AdaptiveValueSource's adaptive_time and
      adaptive_scene route to different builders correctly). The
      manager retains only the SyncClockRuntime pre-acquisition step
      for animated_color (kinds needing this are listed explicitly
      in NEEDS_CLOCK_RUNTIME). Symmetric coverage assertion plus a
      separate assertion that NEEDS_CLOCK_RUNTIME is a subset of the
      registry.

Bundled in: the static->single_color rename plus the HTTPValueStream
/ http_endpoint introduction that were already in flight on this
branch share these files; the registry refactor naturally absorbs
both via the new "single_color" / "static" alias entries and the
_build_http builder.

Tests: 26 new tests cover response-map coverage drift, migration
runner audit-table mechanics + transactional rollback +
frozen-write skip, and the two stream-builder registries. 343
existing storage / API / e2e tests stay green. Ruff clean.
2026-05-22 22:45:28 +03:00
alexei.dolgolyov e24f9d33cc fix(shutdown): survive PC restart with WAL fsync + Win32 session-end guard
Two bugs caused user data ('G502' target's color-strip ref, etc.) to
revert after PC restart while persisting fine across normal app
restarts:

1. SQLite was in WAL mode with synchronous=NORMAL and Database.close()
   was never called. On graceful Python exit the sqlite3 finalizer
   checkpoints the WAL, but on an unclean PC shutdown (power loss,
   forced reboot, or Windows force-terminating pythonw.exe) the WAL
   stayed in OS cache, never reached disk, and the next boot rolled the
   DB back to the last checkpoint -- losing recent edits.

2. Nothing handled WM_QUERYENDSESSION / WM_ENDSESSION, so on PC
   shutdown Windows force-killed pythonw.exe after ~5s and the FastAPI
   lifespan never ran. The 'stop_targets' setting was silently ignored
   and devices were left at their last frame.

Changes:
- Database: PRAGMA synchronous=FULL + wal_autocheckpoint=100, plus an
  explicit wal_checkpoint(TRUNCATE) inside Database.close().
- New utils/win_shutdown.py: hidden top-level window in a daemon thread
  with a ctypes WindowProc that catches WM_QUERYENDSESSION (calls
  ShutdownBlockReasonCreate to extend Windows' 5s hung-app timeout up
  to the ~20s GUI ceiling), fires the shutdown callback, then waits in
  WM_ENDSESSION on a completion event before returning. Also raises
  the process shutdown priority via SetProcessShutdownParameters. All
  Win32 argtypes/restypes are bound once at import to avoid LPARAM
  overflow on x64.
- New shutdown_state.py: leaf module owning the cross-thread Event so
  __main__ does not import the heavy ledgrab.main at startup.
- main.py lifespan: per-step asyncio.wait_for budgets (8s for
  processor_manager.stop_all, 1.5s each for HA/MQTT, etc.) so a hung
  device cannot starve the DB checkpoint, then db.close() and
  shutdown_complete.set() always run.
- __main__.py: install the Windows shutdown guard before tray start;
  install SIGINT/SIGTERM/SIGBREAK handlers only on the tray path
  (uvicorn overwrites them on no-tray); raise server_thread.join to 20s.
- Tests cover WM_QUERYENDSESSION (fires callback, returns TRUE,
  idempotent), WM_ENDSESSION (waits on event, times out cleanly,
  cancel-path returns instantly), signal handler installation, and
  that main and shutdown_state share the same Event instance.
2026-05-22 21:43:41 +03:00
alexei.dolgolyov e4bf58da19 fix(dashboard): stop showing perpetual MODIFIED for un-edited legacy layouts
The MODIFIED hint in the Customize Dashboard panel was driven by
`presetActive`, recomputed on every save/load via strict deep-equal
against each preset. Any drift between a saved layout and the current
defaults — older app versions that hadn't yet had some new perf cells
added, prior buggy merges that appended new registry keys to the end
of perfCells, or stale `visible` values from intermediate dev builds —
left `presetActive` undefined forever and pinned the panel in MODIFIED
state for users who had not actually edited anything.

Split the two concerns:

- `presetActive` keeps driving the chip highlight (recomputed). When
  the layout happens to match a preset exactly the chip lights up.
- New `userModified` boolean drives the MODIFIED indicator. Set to true
  only on actual edits through the panel (visibility / density /
  ordering / select changes) and on JSON import; cleared by applying a
  preset and by Reset.

Legacy saves without the field load as `userModified: false` so the
indicator no longer fires retroactively on data the user never
touched. Also tighten `_mergeWithDefaults` so newly-added registry
keys land at their canonical positions (subsequence detection) when
the saved order is consistent with defaults, which keeps the chip
highlight stable across upgrades.
2026-05-16 17:05:12 +03:00
alexei.dolgolyov f1b0f0eab2 fix(ui): repaint transport-bar uptime as soon as /health responds
The inline transport-uptime ticker only repainted on its 1 s setInterval,
so the field could sit on - for up to ~1 s after page load (and much
longer if init's first /health response landed between ticks - the next
seed then had to wait for the 10 s connection-monitor poll). Dispatch a
serverUptimeChanged DOM event when window.__serverUptime is seeded and
let the inline IIFE re-render on receipt, so the value appears as soon
as the response arrives.
2026-05-16 12:28:57 +03:00
141 changed files with 11445 additions and 1649 deletions
+3
View File
@@ -97,3 +97,6 @@ Thumbs.db
.DS_Store
# Added by code-review-graph
.code-review-graph/
# vex semantic-search embedding cache (auto-downloaded on first --semantic run)
.fastembed_cache/
+24
View File
@@ -0,0 +1,24 @@
# vex configuration — https://github.com/tenatarika/vex
#
# Place this file in your project root as .vex.toml
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
# exclude = [
# "vendor/**",
# "node_modules/**",
# "*.generated.go",
# "dist/**",
# ]
# Default output format: "text", "json", or "compact"
# format = "text"
# Enable semantic embeddings by default (slower indexing, enables meaning-based search)
semantic = true
# Automatically run `vex update` before search if the index is stale
auto_update = true
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
# Changing the embedder requires a full reindex.
# embedder = "minilm-l6-v2"
+347
View File
@@ -0,0 +1,347 @@
# LedGrab Architecture Audit — Remaining Items
Roadmap for the architecture-audit refactor sprint that started 2026-05-22.
This file lists every audit finding that is **not yet addressed**; the ones
already landed in commits `563cbac..2f15fbb` are summarised below for
context.
## Already done (10 commits)
| Commit | Findings addressed |
|---|---|
| `563cbac` | C2, C11, C1 (parallel-change only), C3, C4, C6, C7-streams |
| `29bdacf` | C5 (HA/Z2M swap helper; full ABC deferred) |
| `97dae2c` | H1 |
| `5fec8db` | M4 |
| `98fb61d` | H2 |
| `9f3f346` | M5 |
| `05f73ee` | H6 (bindable extraction only) |
| `3b8f00e` + `c1aa2eb` | C7 store-side |
| `2f15fbb` | H3 |
All commits have ≥1 code-review subagent pass with HIGH findings fixed
before commit. Tests pass on each commit; ruff clean; tsc + bundle build
clean for the frontend commit.
The two CRITICAL **data-safety** items (C2 silent CSS fallback, C11
string-replace JSON migration) are fixed. The two CRITICAL
**parallel-change** problems for color-strip + value-source dispatch are
fixed. The two HIGH dispatch problems (H1 effects, H2 rules) are fixed.
---
## Remaining backend items
### HIGH
#### H4 — `Device.__init__` 40+ params mixing per-type fields
**File:** `server/src/ledgrab/storage/device_store.py:46-150`
The `Device` dataclass constructor accepts ~40 parameters that mix common
fields with DMX-only / DDP-only / Hue-only / Yeelight-only / Wiz-only /
LIFX-only / Govee-only / Nanoleaf-only / SPI-only / Chroma-only /
GameSense-only fields. Setting `hue_username` on a WLED device is
silently ignored.
**Approach:** introduce per-device-type config dataclasses
(`DmxConfig`, `HueConfig`, `DdpConfig`, …) and make `Device.config` a
discriminated union. Per-type validation moves to the config classes.
Wire migration: every existing device row needs to be re-parsed; use the
versioned `MigrationRunner` introduced in Phase 1.2.
**Risk:** medium-high. Touches:
- `storage/device_store.py` — Device dataclass, `from_dict`, `to_dict`,
`create_device`, `update_device`
- `api/schemas/devices.py` — Pydantic schemas
- `api/routes/devices.py` — request validation
- `core/devices/*` — every provider reads device fields
- A new migration to translate flat fields → nested `config`
**Estimated scope:** ~1500 LOC diff, 1-2 dedicated sessions.
#### H5 — `WledTargetProcessor` god class (32 methods, 5 responsibilities)
**File:** `server/src/ledgrab/core/processing/wled_target_processor.py` (1238 LOC)
Conflates:
1. Device connectivity (probe, liveness, reconnect)
2. FPS negotiation (adaptive_fps, keepalive_interval, state_check_interval)
3. LED resampling (`_fit_to_device` — 60 lines of numpy)
4. Preview WebSocket fanout (`_preview_clients`, `_broadcast_led_preview`)
5. Metrics emission (`get_state`, `get_metrics`)
**Approach:** extract `WledDeviceConnector`, `WledPixelSender`,
`TargetFitProcessor`, `TargetPreviewBroadcaster`, `TargetMetricsCollector`.
`WledTargetProcessor` becomes an orchestrator that composes them.
**Risk:** HIGHEST in the audit. This class drives physical LED hardware
in production. A regression caught at runtime (in the user's living
room) is the expensive failure mode. Needs manual verification with at
least one real WLED device after the refactor.
**Coupled with:** C5 (HA/Z2M shared the same shape; should extract a
common `BaseTargetProcessor` ABC at the same time so all three
processors share lifecycle / preview / metrics code).
**Estimated scope:** ~2000 LOC diff, 2-3 dedicated sessions, with manual
device testing after each.
#### H7 — `device-discovery.ts` 1745 LOC
Frontend mirror of H4. The `onDeviceTypeChanged` handler has a giant
switch with 15+ device kinds and 15+ `_showXxxFields` / `_buildXxxItems`
helpers. Adding a device type requires editing 5 separate frontend hooks.
**Approach:** mirror the H4 backend redesign — once the storage layer
has per-type config objects, the frontend can have a per-type field-set
registry. Best done **after** H4 lands so the schemas drive the
registry.
**Estimated scope:** 1-2 sessions; coupled to H4.
#### H8 — `automations.ts` 1410 LOC
Frontend mirror of H2 (rule polymorphism). Already addressed on the
backend in `98fb61d`; the frontend dispatch on `RuleType` is still
hand-rolled.
**Approach:** introduce a rule-type registry on the frontend matching
the backend's `_RULE_HANDLERS` shape.
**Estimated scope:** half a session.
### MEDIUM
#### M1 — `ProcessorManager.add_target` shotgun (11 args, WLED-leak)
**File:** `server/src/ledgrab/core/processing/processor_manager.py:396`
Method is named generically (`add_target`) but accepts `protocol="ddp"`
and `keepalive_interval` — WLED-only fields. HA and Z2M have sibling
methods with their own bespoke params.
**Approach:** extract a `TargetFactory` (per-kind builders, similar to
`value_source_factories.py` from Phase 7). Couple with H5/C5 work.
#### M2 — `TargetContext` god-bag
**File:** `server/src/ledgrab/core/processing/processor_manager.py`
`@dataclass TargetContext` exposes ~8 attributes (device_store,
color_strip_stream_manager, value_stream_manager, metrics_history,
mqtt_manager, ha_manager, …). Processors silently depend on whichever
fields they read. Tests have to construct a huge mock context.
**Approach:** make per-processor explicit dependency injection. Couple
with H5 work.
#### M3 — Validation duplicated across layers
Field-level constraints (composite nesting depth, name uniqueness, span
ranges) are enforced in route + schema + store. Adding a new constraint
means editing 3 places.
**Approach:** move all validation to the model/schema layer (Pydantic
validators + dataclass `__post_init__`). Routes trust the schema; store
trusts the model.
**Risk:** moderate — cross-cutting; needs careful review of which layer
currently owns which constraint.
#### M6 — `ws_stream.py` mixed concerns (699 LOC)
**File:** `server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py`
The worst part (stream-creation dispatch) was fixed in Phase 2.1 — it
now calls `color_strip_kinds.build_stream(source, deps)`. The remaining
699 lines mix config parsing + WebSocket lifecycle + frame loop. Could
extract the frame loop into a separate `PreviewFrameLoop` class.
**Estimated scope:** half a session. Low impact since the parallel-change
problem is already fixed.
#### M7 — No shared frontend API client
**File:** every `static/js/features/*.ts`
`fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every
feature's save / load function. ~25 files.
**Approach:** introduce `static/js/core/api-client.ts` with typed
methods (`get`, `post`, `put`, `delete`) that handle auth, JSON parsing,
error normalisation. Replace `fetchWithAuth` calls across features.
#### M8 — Global `_cached*` `let` vars
Mutable module-level state mutated from multiple feature modules. No
subscription model — features manually `invalidate()` after CRUD.
**Approach:** introduce a reactive cache (EventEmitter pattern or a tiny
store like Nano Stores). Couple with M7 (the API client can drive cache
invalidation on write).
#### M9 — `dashboard.ts` 1421 LOC
Frontend god-module orchestrating + rendering device / target / CSS
cards. Couple with C8/C9/C10 frontend split work.
#### M10 — Duplicate frontend modal classes
`ValueSourceModal`, `StreamEditorModal`, `TargetEditorModal`,
`AddDeviceModal`, etc. each reimplement pristine-check / undo / focus
management.
**Approach:** introduce a `FormModal<T>` base class.
#### M11 — Hardcoded `_getSectionForSource` / `_getTabForSource`
Routing tables duplicated across multiple feature files (streams.ts,
value-sources.ts). Adding a new stream type requires hunting strings.
**Approach:** single routing registry keyed by source_type.
#### M12 — Late imports masking cycles
Partially addressed by the kind registries (Phase 2.1, 2.2). Some
late-imports still exist in `value_stream.py`, `audio_stream.py`, the
target processors. Resolving them requires restructuring module layout
to break the circular dependencies.
**Estimated scope:** small follow-up after H5.
### LOW
#### L1 — `(src as any).field` casts in `value-sources.ts`
Discriminated unions aren't narrowed properly. Couple with C8 frontend
split.
#### L2 — Mutable state without locks
`_preview_clients`, `_last_preview_data`, `_color_stream`,
`_css_stream` are mutated from multiple async tasks without explicit
locks. Production has not exhibited issues but the contract is fragile.
**Approach:** add explicit `asyncio.Lock` per processor. Couple with H5.
#### L3 — `Calibration.validate()` raises instead of returning result
**File:** `server/src/ledgrab/core/capture/calibration.py:164`
All 4 call sites currently rely on the raise; converting to
`ValidationResult` would force every caller to check a return value
without adding safety. **Recommendation:** skip — current design is
appropriate.
#### L4 — `_SOURCE_TYPE_MAP` is module-private
No public `GET /api/v1/source-types` discovery endpoint. Frontend
hardcodes the list of source types in `types.ts`.
**Approach:** add a discovery route + matching frontend fetch. Couple
with H6 frontend split (since `types.ts` is involved).
#### L5 — `AudioValueStream` implicit state machine
**File:** `server/src/ledgrab/core/processing/value_stream.py:169-383`
`get_value()` can be called before `start()`; transitions are implicit.
**Approach:** explicit State pattern. Low value (production callers
always start before reading).
---
## Remaining frontend items (all)
### CRITICAL
- **C8** — `value-sources.ts` 1972 LOC (4 god-functions, type-dispatch ladders)
- **C9** — `graph-editor.ts` 2707 LOC (layout + interaction + state + WS sync + …)
- **C10** — `streams.ts` 2341 LOC (picture / audio / template kitchen-sink)
### Other frontend (severity in main list above)
- **H6 rest** — split remaining ~1100 LOC of `types.ts` into per-entity files
- **H7** — `device-discovery.ts` 1745 LOC (couple with H4)
- **H8** — `automations.ts` 1410 LOC (mirror H2)
- **M7** — shared API client
- **M8** — reactive cache
- **M9** — `dashboard.ts` 1421 LOC
- **M10** — `FormModal<T>` base
- **M11** — routing registry
- **L1** — narrowing the discriminated unions
The frontend remainder is **multi-day work** even when broken up by
finding. Recommended approach: a dedicated frontend sprint with the
typescript-reviewer agent + manual UI testing for each god-module
split. Order:
1. Finish `types.ts` split (H6) — pure organisation, low risk, unblocks
the rest
2. Introduce API client (M7) — every feature file gains a cleaner shape
3. Split `value-sources.ts` (C8) — uses the API client + per-type
registry pattern
4. Split `streams.ts` (C10)
5. Split `graph-editor.ts` (C9) — needs the most care; the file owns
the entire visual editor
6. Polish: `dashboard.ts` (M9), `device-discovery.ts` (H7),
`automations.ts` (H8), `FormModal` (M10), routing registry (M11),
reactive cache (M8), narrowing (L1)
---
## Recommended ordering for future sessions
### Session A — Frontend sprint (multi-day)
Address H6-rest, C8, C9, C10, H7, H8, M7-M11, L1. See order above.
Critical to have typescript-reviewer feedback + manual UI testing after
each split.
### Session B — Device redesign (1-2 sessions)
Address H4 alone. Touches device storage + provider classes; needs a
data migration. Once H4 lands, H7 frontend mirror can follow.
### Session C — BaseTargetProcessor ABC (2-3 sessions)
Address C5 (full) + H5 + M1 + M2 + L2 together. Highest risk in the
audit because it drives physical LED hardware. Each step needs manual
verification with a real device.
### Session D — Polish (half a session)
Address M3, M6 (remainder), M12 (remainder), L3 (decision: skip), L4,
L5.
---
## Pattern reference for new contributors
Three registry-pattern templates that already exist in the codebase and
should be the model for the remaining dispatch ladders:
1. **Class-level handler dict + import-time coverage assertion**
- `core/processing/effect_stream.py::_RENDERERS`
(`@_effect_renderer` decorator + `@_collect_effect_renderers`
class decorator)
- `core/automations/automation_engine.py::AutomationEngine._RULE_HANDLERS`
(module-level binding after class definition)
- `api/routes/output_targets.py::_TARGET_RESPONSE_BUILDERS`
(response-shape dispatch keyed by storage class)
2. **Per-type free functions + dependency-bag dataclass**
- `core/processing/color_strip_kinds.py` (`StreamDeps` + `STREAM_BUILDERS`)
- `core/processing/value_kinds.py` (`ValueStreamDeps` + `STREAM_BUILDERS`)
- `storage/value_source_factories.py` (`CREATE_BUILDERS` + `UPDATE_APPLIERS`)
3. **Versioned migration runner**
- `storage/data_migrations.py` (`MigrationRunner` + `DataMigration` ABC)
- Used for any storage rename / field-shape change in the future.
- Audit-table contract: atomic transaction covers
applied-check + apply + record, so partial-failure cannot leave
data rewritten but unrecorded.
Adding a new feature that touches dispatch should reach for one of
these three patterns before writing a fresh if/elif chain.
-4
View File
@@ -55,10 +55,6 @@ The Android app (`android/app/build.gradle.kts`) installs the server package wit
| [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) | Reusable CI/CD patterns: Gitea Actions, cross-build, NSIS, Docker |
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
## Task Tracking via TODO.md
Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the TodoWrite tool** — all progress tracking goes through `TODO.md`.
## Documentation Lookup
**Use context7 MCP tools for library/framework documentation lookups** (FastAPI, OpenCV, Pydantic, yt-dlp, etc.) instead of relying on potentially outdated training data.
+162
View File
@@ -0,0 +1,162 @@
# Production Review — Remaining Items
Output of the multi-agent production review (security / Python / TypeScript /
performance / architecture / code-quality). Each entry below is something
the original audit flagged and the autonomous hardening pass deliberately
did **not** address — either because it needs design input, profiling
validation, or a multi-day refactor that should land in its own session.
The hardening pass landed everything else: see git log between `master` and
the head of the review branch for the applied changes (URL-scheme +
malicious-input rejection, IconSelect XSS escape, MiniSelect for forbidden
plain `<select>`s, WebSocket Origin allow-list, /docs auth-gate, security
headers middleware, streaming upload size caps, fire-and-forget task
tracking + drain resilience in MQTT runtime, discovery_watcher task
tracking, asyncio.gather return_exceptions, secret_box encryption for MQTT
/ Hue / Govee credentials with auto-migration, SSRF-validated update
redirects, single source of truth for IP classification in
`utils/net_classify.py`, allowlist + parity test for inbound WS events,
typed `Window` globals, and more).
---
## Architecture refactors (multi-day — own session)
- [ ] **Split `core/processing/value_stream.py`** (1856 LOC, 14 stream classes)
into a `value_streams/` package. Each value-stream type gets its own
file ≤300 LOC; `manager.py` holds `ValueStreamManager`.
- [ ] **Split `storage/color_strip_source.py`** (1841 LOC, 18 source kinds)
into a `color_strip_sources/` package mirroring `value_streams/`.
- [ ] **Frontend file splits**`graph-editor.ts` (2707), `streams.ts`
(2335), `value-sources.ts` (1889), `types.ts` (1062). Highest-churn
modules; mixed UI / state / network responsibilities.
- [ ] **Layering reversal**: introduce a neutral `domain/` package and move
shared DTOs (`FilterInstance`, `CalibrationConfig`, etc.) into it so
`storage/` no longer imports `core/`. Eliminates 7+ layering
violations and the lazy-import hacks used to break the resulting
circulars.
- [ ] **`main.py` boot refactor** — extract import-time side effects into
`bootstrap.py` + `create_app()` factory. `lifespan()` becomes the
single place that wires stores and managers.
- [ ] **DI consolidation** — replace `api/dependencies.py` getter sprawl
(30+ `get_*()` functions reading a process-global `_deps` dict) with
a single typed `get_container()` dependency. Makes test-overrides
trivial; ban direct getter calls in handler bodies.
- [ ] **Exception hierarchy** — define `ledgrab/errors.py` (`LedGrabError`,
`NotFoundError`, `ValidationError`, `RemoteUnavailableError`,
`SSRFBlockedError`). Move HTTP translation into a FastAPI exception
handler. Stop raising `HTTPException` from `utils/safe_source.py`.
- [ ] **Lazy-import audit** — 289 in-function `from ledgrab.*` imports.
Specifically `core/processing/daylight_settings.py` imports
`api.dependencies` (core → api inversion). Pass the database in via
the constructor instead of service-locator lookup.
## Performance (profile before applying)
- [ ] **`composite_stream.py` blend modes** — pre-allocate scratch buffers
in `_blend_override / overlay / hard_light / soft_light / difference
/ exclusion`. Each currently allocates per frame (`mul`, `scr`,
`blended`, `np.where(...)`). At 100 LEDs × 30 fps × N layers this
adds up.
- [ ] **`mapped_stream` / `composite_stream` zone resize** — replace the
per-channel `np.interp` calls with a cached `floor/ceil/frac` LUT
(same trick as `wled_target_processor._fit_to_device`) or a single
`cv2.resize` call on the (N,3) array. `np.interp` allocates a new
`float64` array per channel per frame even on cache-hit.
- [ ] **`processed_stream._processing_loop`** — add ping-pong output
buffers and pass them as `out=` to filter `process_strip()` calls.
Today every filter that returns a fresh allocation costs us a copy
per frame. Also: the loop uses `time.sleep` instead of an
event-driven wait on the input stream — input updates faster than
30 fps see up to `frame_time` of latency.
- [ ] **`mqtt_client.py` `send_pixels`** — add a binary publish path (or
at minimum cache the outer dict skeleton). Today every frame
`pixels.tolist()` + `json.dumps` for ~300 LEDs × 30 fps × N devices.
- [ ] **Frontend `static/js/features/color-strips/test.ts`** — cache
`ImageData` per canvas (`canvas._imageData`); only re-create on
dimension change; use a `Uint32Array` view to copy pixels in one
loop instead of the per-pixel JS loop. Border-overlay rebuild on
every frame should also be debounced to dimension changes only.
- [ ] **`ws_stream.py` composite branch** — pre-allocate a `bytearray`
sized to the largest frame and write into slices instead of
`b"".join(tobytes()) per layer` every iteration. Same anti-pattern
in `wled_target_processor._broadcast_led_preview`.
- [ ] **Preview broadcast slow-client guard**`asyncio.gather` over
preview clients waits for the slowest. Move to `asyncio.wait` with a
timeout and drop slow clients, or fire-and-forget with a
`ws.application_state` filter.
## Security (deferred — non-trivial or design-sensitive)
- [ ] **Content-Security-Policy header** — would need careful tuning
because the UI uses inline event handlers / Jinja templates.
Mis-set CSP would break the app silently. Defer until templates can
move to event-delegated handlers, then add a strict policy.
- [ ] **`api/auth.py` exception specificity** — 9 `except Exception:`
sites. Most are intentional best-effort `websocket.send_json`
swallows (the WS is already closed or about to be), but the auth
decision path itself could be tightened to specific types
(`jwt.InvalidTokenError`, `OSError`) + `logger.exception` for
observability.
- [ ] **Hue bridge cert pinning**`httpx.AsyncClient(verify=False)` for
Hue bridge (self-signed cert by design). Should record the
certificate fingerprint at pairing time and pin it on subsequent
requests; otherwise an on-path attacker can MITM the bridge.
## Mechanical / code-quality (low risk, high line-count)
- [ ] **i18n parity****328** keys missing in `ru.json`, **325** missing
in `zh.json`. Examples: `section.hide`, `filters.hsl_shift`,
`filters.contrast`, `filters.temporal_blur`,
`filters.audio_filter_template.desc`. Russian and Chinese users
currently see raw keys for these. This is translation work, not
code work.
- [ ] **`Optional[T]``T | None`** (PEP 604) — large mechanical refactor
across the codebase. Can be auto-fixed via `ruff check --fix
--select UP007`. Worth doing once the file splits land.
- [ ] **Hot-path `logger.error(f"...")` → `logger.error("... %s", e)`**
lazy-eval — mostly cosmetic; ~200 sites. The f-string still builds
the message even when DEBUG is off.
- [ ] **Remaining `(window as any)` sites** — typed `global-types.d.ts`
is in place and new code uses `window.foo` directly, but ~80
existing sites still have the cast. Per-site mechanical cleanup.
Add `eslint`-equivalent guard (TS rule) to prevent new ones.
- [ ] **Magic numbers → named constants** in processing hot paths —
`_FILTER_RECHECK_EVERY_N_FRAMES = 30` in
`core/processing/processed_stream.py:159`; `5 ms` / `5 s` /
`30 iterations` literals in `wled_target_processor.py:890,893,915`.
- [ ] **Standardise `from __future__ import annotations`** across the
codebase. Some modules use the future-annotation form, others stick
with `Optional[...]`. Enforce one via ruff `FA` rules.
## Test gaps
- [ ] **Route-level integration test** for the WLED scheme inference —
POST `/api/v1/devices` with `{"url": "192.168.1.42",
"device_type": "wled"}` and assert the stored device has
`url == "http://192.168.1.42"`. The helper is exhaustively
unit-tested but no integration test exercises the create/update
flow end-to-end.
- [ ] **IPv6 public address regression** — extend `test_url_scheme.py`
with explicit assertions for `2001:db8::1` and similar public IPv6
literals (the bare-label fallback used to misclassify these). The
helper does the right thing today via the IPv6 probe added during
the hardening pass, but no test pins it.
## Pre-existing issues surfaced during the audit (not in our diff)
These were flagged by the auditors but predate the review session — kept
here as a future-work backlog:
- [ ] **`icon-select.ts:_buildGrid` `item.icon` is interpolated raw** —
documented as "trusted SVG by design". If callers ever feed
user-supplied icon strings, that's an XSS sink. Audit every caller
that builds `IconSelectItem.icon` from non-constant data and
reject HTML there.
- [ ] **`devices.py:461` `manager.update_device_info(device_url=update_data.url)`**
receives `None` when a PATCH omits `url` (rename / icon-only edit).
The processor never re-syncs in that case. Should pass
`existing.url` (after normalization) or skip the call.
- [ ] **`asyncio.gather` over uncapped client lists** in preview broadcasts
— slow clients block the loop. Already noted under Performance
above; pre-existing.
+165
View File
@@ -1,5 +1,170 @@
# LedGrab TODO
## HTTP polling automation trigger
Goal: a new automation trigger that periodically polls an HTTP endpoint
and activates a scene when the response matches a condition. Split into
three single-responsibility entities so the endpoint can be reused
beyond automations (e.g. as a value-source driving brightness/color):
- `HTTPEndpoint` (storage/http_endpoint.py) — connection definition:
URL + auth + headers + timeout. NO polling cadence; NO extraction.
- `HTTPValueSource` (storage/value_source.py, source_type='http') —
references an endpoint + owns json_path + interval + min/max + EMA
smoothing. Backed by `HTTPValueStream` (core/processing/value_stream.py)
which lives under the existing `ValueStreamManager` (ref-counted,
one poll task per unique value source).
- `HTTPPollRule` (storage/automation.py) — thin: `{value_source_id,
operator, value}`. Reads `stream.get_raw_value()` from the value
source and compares with `_apply_operator`.
Pivoted from a 2-entity shape mid-build (was: HTTPSource+rule with
interval+json_path mushed). The 3-entity shape mirrors HA's pattern
(HomeAssistantSource → HAEntityValueSource → rule).
### Phase 1 — endpoint + value source + thin rule (backend) ✅
- [x] `storage/http_endpoint.py` — `HTTPEndpoint` dataclass with
secret_box auth_token encryption + `__post_init__` plaintext
invariant. NO `default_interval_s` (moved to value source).
- [x] `storage/http_endpoint_store.py` — `HTTPEndpointStore` with
`_migrate_plaintext_tokens()`. ID prefix `htep_`.
- [x] `storage/database.py` — `"http_endpoints"` in `_ENTITY_TABLES`
(replaces the old `"http_sources"`).
- [x] `storage/value_source.py` — added `HTTPValueSource` alongside
`HAEntityValueSource` (endpoint_id, json_path, interval_s,
min/max, smoothing). Registered in `_VALUE_SOURCE_MAP`.
- [x] `storage/value_source_store.py` — CRUD branch for `source_type =
"http"` + new kwargs on create/update.
- [x] `core/processing/value_stream.py` — `HTTPValueStream` with poll
task + `get_value()` (normalized 0-1) + `get_raw_value()` (raw
extracted value). Dispatched in `ValueStreamManager._create_stream`.
Manager now takes `http_endpoint_store` so the stream can resolve
endpoints at fetch time.
### Phase 2 — rule + engine wiring ✅
- [x] `storage/automation.py` — `HTTPPollRule` is now thin: just
`{value_source_id, operator, value}` (no http_source_id, no
json_path on the rule). Legacy keys silently dropped on load.
- [x] `core/automations/automation_engine.py` — drops the standalone
http_poll_manager; takes `value_stream_manager`. Engine
`_sync_value_stream_refs` acquires/releases value streams for
every enabled HTTPPollRule, mirroring the HA/MQTT sync pattern.
`_evaluate_http_poll` reads `stream.get_raw_value()` and applies
the operator. `_apply_operator` kept at module top.
- [x] `api/schemas/automations.py` — RuleSchema fields are now
`value_source_id + operator + value` (dropped http_source_id +
json_path).
- [x] `api/routes/automations.py` — `http_poll` factory updated.
### Phase 3 — CRUD endpoints + wiring ✅
- [x] `api/schemas/http_endpoints.py` — Create/Update/Response/List/Test
(no interval field; that's on the value source).
- [x] `api/routes/http_endpoints.py` — full CRUD + `/test` +
plaintext-http-token warning.
- [x] `api/schemas/value_sources.py` — `HTTPValueSource{Create,Update,Response}`
added to the discriminated unions.
- [x] `api/routes/value_sources.py` — `_RESPONSE_MAP` entry for
`HTTPValueSource`.
- [x] `api/__init__.py` — `http_endpoints_router` registered.
- [x] `api/dependencies.py` — `get_http_endpoint_store` (dropped the
http_poll_manager getter).
- [x] `main.py` — instantiate `HTTPEndpointStore`, pass it through
`ProcessorDependencies`, wire `value_stream_manager` +
`value_source_store` into `AutomationEngine`.
- [x] `core/processing/processor_manager.py` — `ProcessorDependencies`
gains `http_endpoint_store`; threaded into `ValueStreamManager`.
### Phase 4 — tests ✅
- [x] `tests/storage/test_http_endpoint_store.py` — 14 tests (CRUD +
auth_token encryption + headers + case-insensitive Authorization).
- [x] `tests/core/test_automation_engine.py` — `TestApplyOperator` +
`TestHTTPPollRuleEvaluation` (new shape: mock ValueStreamManager
with `_streams` dict) + `TestSyncValueStreamRefs` (acquire /
release / disabled-ignored) + `TestHTTPValueStreamExtraction`
(`_extract_simple_path` now lives in value_stream.py).
- [x] `tests/api/routes/test_http_endpoints_routes.py` — CRUD shape, no
auth_token leak in responses, schema-layer method allowlist,
CRLF / invalid header rejection, `/test` endpoint, LAN policy.
- [x] Removed: `tests/core/test_http_poll_manager.py` (manager deleted —
polling now lives inside `HTTPValueStream`).
- [x] Full suite: 1426 passed, ruff clean.
### Phase 5 — frontend ✅
- [x] `static/js/features/http-endpoints.ts` (new, ~540 LOC) — endpoint
CRUD, modal subclass with dirty-check, headers row editor, test
result rendering, card builder, event delegation. Mirrors
`home-assistant-sources.ts`.
- [x] `templates/modals/http-endpoint-editor.html` (new) — sectioned
rack-panel modal (Identity / Request / Headers / Notes) with
IconSelect method picker, password-toggle on auth token, inline
Test button + result block.
- [x] `static/js/features/value-sources.ts` — added `http` branch with
EntitySelect over `httpEndpointsCache`, edit-data/defaults,
`onValueSourceTypeChange` section toggle, save-payload assembly
+ required-field validation.
- [x] `templates/modals/value-source-editor.html` — new
`#value-source-http-section` with endpoint picker + json_path +
interval + min/max + smoothing.
- [x] `static/js/features/automations.ts` — `http_poll` rule type with
operator IconSelect + value-source EntitySelect; hides Value
field when operator is `exists`.
- [x] `static/js/features/integrations.ts` — `csHTTPEndpoints` section,
tree/tab entry, render + reconcile + delegation paths.
- [x] `static/js/types.ts` — `HTTPEndpoint`, `HTTPMethod`,
`HTTPEndpointListResponse`, `HTTPTestRequest/Response`,
`HTTPValueSource`, `HTTPPollOperator`; extended `RuleType` +
`AutomationRule`.
- [x] `static/js/core/state.ts` — `httpEndpointsCache` (`/http/endpoints`).
- [x] `static/js/core/icons.ts` — `http: P.globe` in
`_valueSourceTypeIcons`.
- [x] `templates/index.html` — includes
`modals/http-endpoint-editor.html`.
- [x] Locales: 77 new keys per file in `en.json` / `ru.json` /
`zh.json` (parity confirmed).
- [x] Verification: `npx tsc --noEmit` clean; `npm run build` clean
(app.bundle.css 366.6kb, app.bundle.js 2.7mb).
### Follow-ups (out of scope for initial PR)
- [ ] **Global concurrency cap / minimum interval.** Each
`HTTPValueStream` runs its own task at `interval_s` (min 1s); no
project-wide cap. Reviewer flagged: pick a min (e.g. 5s) + max
active runtimes (e.g. 32) + shared `httpx.AsyncClient` with
`limits=httpx.Limits(max_connections=N)`.
- [ ] **DNS-rebinding hardening.** `safe_request_bounded` validates
the URL hostname's resolved IPs once; httpx independently
re-resolves. The window is short but not zero. True fix: pin
to the validated IP + set Host header (and SNI for HTTPS). This
affects every outbound caller (`safe_fetch`, weather, image
sources) — handle as a project-wide hardening, not local to
this feature.
- [ ] **`delete_http_endpoint` orphan refs.** When an admin deletes an
endpoint referenced by N value sources, the value-stream task
keeps polling until its source is also deleted. Same shape as
the MQTT defect — fix both together (refuse-with-409 when in
use, or cascade value-source deletion).
- [ ] **Per-endpoint `connected` / last-poll status on the response**
(frontend agent flagged). `HTTPEndpointResponse` has no live
status, unlike HA/MQTT sources. Card LEDs default to "on".
Could aggregate `last_status_code` / `last_error` from all
`HTTPValueStream` instances referencing the endpoint and surface
on `GET /http/endpoints/{id}`.
- [x] **Per-endpoint live `/test` after save** — added `POST
/http/endpoints/{id}/test` (runs stored config server-side so the
auth token never round-trips) and wired a flask-icon test action
on the endpoint card (toasts the result). Custom-headers section
and inline test-result UI in the editor modal also restyled to
match the `.group-child-row` and result-card vocabulary.
- [ ] **Dedicated icon for HTTP value source / endpoint** (frontend
agent flagged). Both use `P.globe` — visually fine in practice
but adding a `cable`/`webhook` glyph in `icon-paths.ts` would
improve differentiation.
## Multi-broker MQTT refactor
Goal: drop the global `MQTTService` / `MQTTConfig`. Every MQTT consumer
+7 -4
View File
@@ -6,15 +6,18 @@ server:
# For LAN access, add your machine's IP, e.g. "http://192.168.1.100:8080"
cors_origins:
- "http://localhost:8080"
- "http://192.168.2.100:8080"
auth:
# API keys — required for any non-loopback (LAN) request.
# When empty:
# When empty (default):
# - loopback (127.0.0.1, ::1, localhost) requests are allowed anonymously
# - LAN requests are REJECTED with 401 (security default)
# To enable LAN access, add one or more label: "api-key" entries below
# and send `Authorization: Bearer <api-key>` with each request.
# Generate secure keys: openssl rand -hex 32
# To enable LAN access, uncomment the example below and replace the value
# with a secret you generated yourself (e.g. `openssl rand -hex 32`).
# The previous default `dev: "development-key-change-in-production"` has
# been removed — it shipped as a publicly-known token and any deployment
# that still uses it grants full LAN access to anyone on the network.
api_keys:
dev: "development-key-change-in-production"
+86 -28
View File
@@ -288,23 +288,72 @@ $pythonExe = $resolvedPython
Write-Info "Starting $Module on port $Port..."
if ($SkipBrowser) { $env:LEDGRAB_RESTART = '1' }
# Redirect the child's stdout/stderr to a log file. Without this, inheriting
# the parent shell's handles via Start-Process -WindowStyle Hidden can cause
# the child to exit immediately when those handles aren't real console fds
# (e.g. when restart.ps1 is driven from WSL/Git-Bash).
$logPath = Join-Path $env:TEMP ("ledgrab-{0}-{1}.log" -f $Module, $Port)
$errPath = "$logPath.err"
# Launch python.exe directly with no parent-handle inheritance. We used to
# wrap it in `cmd /c python ... 1>log 2>err` so the parent powershell could
# tail crash logs, but that left an empty cmd.exe window hanging around for
# the full server lifetime (cmd had to live to hold the redirect handles).
# Instead, let python claim its own console window — the user sees the live
# server log there, and there's no spurious cmd window.
#
# Why WMI Win32_Process.Create rather than Start-Process or
# [Diagnostics.Process]::Start? Both of those go through CreateProcess with
# bInheritHandles=true, which leaks the parent shell's pipe handles into
# the new Python process. When the caller is Git-Bash (`restart.ps1 |
# tail -10`), the bash pipe then stays open for the full server lifetime,
# hanging the bash invocation even after powershell exits. WMI's
# Win32_Process.Create uses CreateProcess with bInheritHandles=FALSE.
$argList = @()
$argList += $launchArgs
$argList += @('-m', $Module)
$startedProc = Start-Process -FilePath $pythonExe `
-ArgumentList $argList `
-WorkingDirectory $ServerRoot `
-WindowStyle Hidden `
-RedirectStandardOutput $logPath `
-RedirectStandardError $errPath `
-PassThru
$startedPid = $startedProc.Id
# Quote each arg defensively in case a future caller adds whitespace.
function Quote-CmdArg {
param([string]$Arg)
if ($Arg -match '[\s"]') {
return '"' + ($Arg -replace '"', '\"') + '"'
}
return $Arg
}
$quotedArgs = ($argList | ForEach-Object { Quote-CmdArg $_ }) -join ' '
$pyQ = Quote-CmdArg $pythonExe
$cmdLine = $pyQ + ' ' + $quotedArgs
# Win32_Process.Create starts detached with no parent-handle inheritance.
# Returns @{ ProcessId; ReturnValue (0 = success) }.
# Title sets the visible console-window title so the user can tell at a
# glance which server the window belongs to (useful when running real +
# demo side by side on different ports).
$startupInfo = New-CimInstance -ClassName Win32_ProcessStartup `
-ClientOnly `
-Property @{ Title = "LedGrab - $Module (port $Port)" }
$wmiResult = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
CommandLine = $cmdLine
CurrentDirectory = $ServerRoot
ProcessStartupInformation = $startupInfo
} -ErrorAction SilentlyContinue
if (-not $wmiResult -or $wmiResult.ReturnValue -ne 0) {
Write-Warning "WMI Win32_Process.Create failed (ReturnValue=$($wmiResult.ReturnValue)); falling back to Start-Process"
# Fallback path — Start-Process inherits parent handles, so a piped
# caller may hang. Acceptable here because this branch only runs when
# WMI itself is broken (very rare).
$startedProc = Start-Process -FilePath $pythonExe `
-ArgumentList $argList `
-WorkingDirectory $ServerRoot -PassThru
$startedPid = if ($startedProc) { $startedProc.Id } else { 0 }
} else {
$startedPid = [int]$wmiResult.ProcessId
}
# Confirm the process is actually our server (defensive — WMI sometimes
# returns a PID for a transient ancestor on heavily loaded boxes).
Start-Sleep -Milliseconds 250
if (-not (Get-Process -Id $startedPid -ErrorAction SilentlyContinue)) {
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
if ($rescanned) { $startedPid = $rescanned.ProcessId } else { $startedPid = 0 }
}
# ---- Poll readiness --------------------------------------------------------
@@ -316,28 +365,37 @@ $deadline = (Get-Date).AddSeconds($StartupTimeoutSec)
$ready = $false
while ((Get-Date) -lt $deadline) {
# Bail early if the process has already exited — something went wrong.
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
if (-not $proc) { break }
if ($startedPid -gt 0) {
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
if (-not $proc) {
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
if ($rescanned) { $startedPid = $rescanned.ProcessId } else { break }
}
} else {
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
if ($rescanned) { $startedPid = $rescanned.ProcessId }
}
if (Test-PortOpen -Port $Port) { $ready = $true; break }
Start-Sleep -Milliseconds 500
}
if ($ready) {
Write-Info "Server ready on port $Port (PID $startedPid)"
if ($startedPid -gt 0) {
Write-Info "Server ready on port $Port (PID $startedPid)"
} else {
Write-Info "Server ready on port $Port"
}
exit 0
}
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
if (-not $proc) {
Write-Warning "Server process $startedPid exited before binding port $Port"
} else {
Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s"
}
if (Test-Path $errPath) {
$tail = Get-Content $errPath -Tail 20 -ErrorAction SilentlyContinue
if ($tail) {
Write-Warning "Last stderr lines from $errPath :"
$tail | ForEach-Object { Write-Warning " $_" }
if ($startedPid -gt 0) {
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
if (-not $proc) {
Write-Warning "Server process $startedPid exited before binding port $Port (check the server console window for the error)"
} else {
Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s"
}
} else {
Write-Warning "Could not locate server process; port $Port did not bind within ${StartupTimeoutSec}s"
}
exit 1
+77 -3
View File
@@ -6,6 +6,7 @@ shows a system-tray icon with **Show UI** / **Exit** actions.
import asyncio
import os
import signal
import socket
import sys
import threading
@@ -42,6 +43,8 @@ from ledgrab.config import get_config # noqa: E402
from ledgrab.server_ref import set_server, set_tray # noqa: E402
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
from ledgrab.utils import setup_logging, get_logger # noqa: E402
from ledgrab.utils.platform import is_windows # noqa: E402
from ledgrab.utils.win_shutdown import WindowsShutdownGuard # noqa: E402
setup_logging()
logger = get_logger(__name__)
@@ -117,10 +120,22 @@ def main() -> None:
server = uvicorn.Server(uv_config)
set_server(server)
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
# ``shutdown_complete`` once it has stopped targets and checkpointed the
# DB; the Windows guard waits on that event before letting the OS finish
# ending the session. Without this, the entire shutdown lifespan never
# runs on PC reboot — devices stay on and the SQLite WAL is lost.
guard = _install_os_shutdown_guard(server)
use_tray = PYSTRAY_AVAILABLE and (sys.platform == "win32" or _force_tray())
if use_tray:
logger.info("Starting with system tray icon")
# Install signal handlers BEFORE starting the uvicorn thread so a
# SIGINT/SIGBREAK during startup still triggers a clean shutdown.
# We do NOT install them on the no-tray path because uvicorn's
# ``server.run()`` overwrites SIGINT/SIGTERM with its own handlers.
_install_signal_handlers(server)
# Uvicorn in a background thread
server_thread = threading.Thread(
@@ -147,12 +162,20 @@ def main() -> None:
set_tray(tray)
tray.run()
# Tray exited — wait for server to finish its graceful shutdown
server_thread.join(timeout=10)
# Tray exited — wait for server to finish its graceful shutdown.
# Use a longer join than the lifespan's own ~18 s budget so we don't
# cut the DB checkpoint short on a slow disk.
server_thread.join(timeout=20)
if guard is not None:
guard.stop()
else:
if not PYSTRAY_AVAILABLE:
logger.info("System tray not available (install pystray for tray support)")
server.run()
try:
server.run()
finally:
if guard is not None:
guard.stop()
def _request_shutdown(server: uvicorn.Server) -> None:
@@ -160,6 +183,57 @@ def _request_shutdown(server: uvicorn.Server) -> None:
server.should_exit = True
def _install_os_shutdown_guard(server: uvicorn.Server) -> "WindowsShutdownGuard | None":
"""Install the OS-shutdown safety net (Windows only).
Returns the guard so the caller can ``stop()`` it on normal exit, or
``None`` on platforms where no guard is needed.
"""
if not is_windows():
return None
# ``shutdown_state`` is a leaf module — importing it does NOT pull in
# ``ledgrab.main`` and its global stores. uvicorn loads ``main`` lazily
# via the import string ``"ledgrab.main:app"`` once it starts serving.
from ledgrab.shutdown_state import shutdown_complete
guard = WindowsShutdownGuard(
on_shutdown=lambda: _request_shutdown(server),
shutdown_complete=shutdown_complete,
)
if guard.start():
logger.info("Windows shutdown guard installed")
else:
logger.warning("Windows shutdown guard failed to start")
return guard
def _install_signal_handlers(server: uvicorn.Server) -> None:
"""Catch terminal/admin shutdown signals and trigger graceful exit.
Uvicorn already installs SIGINT/SIGTERM handlers when ``server.run()``
is called on the main thread (the no-tray path). For the tray path,
uvicorn runs on a background thread and skips signal installation, so
we install our own here. SIGBREAK is Windows-specific and fires on
Ctrl-Break and in some service-stop scenarios.
"""
def _handler(signum, frame): # noqa: ANN001 - signal handler signature
logger.warning("Signal %s received — requesting shutdown", signum)
_request_shutdown(server)
candidates = ["SIGINT", "SIGTERM", "SIGBREAK"]
for name in candidates:
sig = getattr(signal, name, None)
if sig is None:
continue
try:
signal.signal(sig, _handler)
except (ValueError, OSError) as e:
# ValueError: not on main thread; OSError: signal not supported here.
logger.debug("Could not install handler for %s: %s", name, e)
def _force_tray() -> bool:
"""Allow forcing tray on non-Windows via LEDGRAB_TRAY=1."""
import os
+2
View File
@@ -27,6 +27,7 @@ from .routes.update import router as update_router
from .routes.assets import router as assets_router
from .routes.home_assistant import router as home_assistant_router
from .routes.mqtt import router as mqtt_router
from .routes.http_endpoints import router as http_endpoints_router
from .routes.game_integration import router as game_integration_router
from .routes.audio_processing_templates import router as audio_processing_templates_router
from .routes.audio_filters import router as audio_filters_router
@@ -59,6 +60,7 @@ router.include_router(update_router)
router.include_router(assets_router)
router.include_router(home_assistant_router)
router.include_router(mqtt_router)
router.include_router(http_endpoints_router)
router.include_router(game_integration_router)
router.include_router(audio_processing_templates_router)
router.include_router(audio_filters_router)
+42 -9
View File
@@ -11,14 +11,13 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
from ledgrab.config import get_config
from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
logger = get_logger(__name__)
# Security scheme for Bearer token
security = HTTPBearer(auto_error=False)
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
def is_auth_enabled() -> bool:
"""Return True when at least one API key is configured."""
@@ -26,15 +25,15 @@ def is_auth_enabled() -> bool:
def _is_loopback(host: str | None) -> bool:
"""Return True when *host* is a loopback address."""
"""Return True when *host* is a loopback address.
Delegates to :func:`ledgrab.utils.net_classify.is_loopback` so this
auth gate, the SSRF guard in ``safe_source``, and the LAN-default
inference in ``url_scheme`` share one classification source.
"""
if not host:
return False
# Strip IPv6 brackets and zone IDs
h = host.strip().lower()
if h.startswith("[") and h.endswith("]"):
h = h[1:-1]
h = h.split("%", 1)[0]
return h in _LOOPBACK_HOSTS
return _classify_is_loopback(host)
def verify_api_key(
@@ -142,6 +141,23 @@ def require_authenticated(label: str) -> None:
WS_AUTH_CLOSE_CODE = 4401
WS_ORIGIN_CLOSE_CODE = 4403
"""Close code sent when a WebSocket request fails the Origin allowlist."""
def _is_origin_allowed(origin: str | None, allowed: list[str]) -> bool:
"""Return True when *origin* matches one of the configured CORS origins.
Non-browser clients (Python scripts, curl) don't send Origin — those are
allowed through; the Bearer-token check on the auth handshake is the
primary defence in that case. Browsers always set Origin, so this only
blocks cross-site WebSocket connection attempts (CSWSH).
"""
if not origin:
return True
return origin in set(allowed or [])
async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) -> str | None:
"""Accept the WebSocket, then perform first-message auth handshake.
@@ -152,6 +168,23 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
Returns the caller label on success, ``None`` on failure (connection
already closed).
"""
# Reject cross-site WebSocket attempts before accepting — a browser-based
# attacker page cannot forge the Origin header, so an Origin mismatch is
# a strong signal even before the token check. Non-browser clients
# legitimately omit Origin; those fall through to the auth handshake.
config = get_config()
origin = websocket.headers.get("origin")
if not _is_origin_allowed(origin, config.server.cors_origins):
logger.warning(
"Rejected WebSocket from origin %r (not in cors_origins)",
origin,
)
try:
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
except Exception:
pass
return None
await websocket.accept()
label = await verify_ws_auth(websocket, timeout=timeout)
if label is None:
+7
View File
@@ -37,6 +37,7 @@ from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.pattern_template_store import PatternTemplateStore
@@ -165,6 +166,10 @@ def get_mqtt_manager() -> MQTTManager:
return _get("mqtt_manager", "MQTT manager")
def get_http_endpoint_store() -> HTTPEndpointStore:
return _get("http_endpoint_store", "HTTP endpoint store")
def get_audio_processing_template_store() -> AudioProcessingTemplateStore:
return _get("audio_processing_template_store", "Audio processing template store")
@@ -237,6 +242,7 @@ def init_dependencies(
game_event_bus: GameEventBus | None = None,
mqtt_store: MQTTSourceStore | None = None,
mqtt_manager: MQTTManager | None = None,
http_endpoint_store: HTTPEndpointStore | None = None,
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
pattern_template_store: PatternTemplateStore | None = None,
):
@@ -272,6 +278,7 @@ def init_dependencies(
"game_event_bus": game_event_bus,
"mqtt_store": mqtt_store,
"mqtt_manager": mqtt_manager,
"http_endpoint_store": http_endpoint_store,
"audio_processing_template_store": audio_processing_template_store,
"pattern_template_store": pattern_template_store,
}
+5 -4
View File
@@ -15,7 +15,7 @@ from ledgrab.api.schemas.assets import (
from ledgrab.config import get_config
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
from ledgrab.utils import get_logger, read_upload_capped
logger = get_logger(__name__)
@@ -93,10 +93,11 @@ async def upload_asset(
config = get_config()
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
data = await file.read()
if len(data) > max_size:
try:
data = await read_upload_capped(file, max_size)
except ValueError:
raise HTTPException(
status_code=400,
status_code=413,
detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
)
@@ -23,6 +23,7 @@ from ledgrab.storage.automation import (
ApplicationRule,
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
MQTTRule,
Rule,
StartupRule,
@@ -75,6 +76,11 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
state=s.state or "",
match_mode=s.match_mode or "exact",
),
"http_poll": lambda: HTTPPollRule(
value_source_id=s.value_source_id or "",
operator=s.operator or "equals",
value=s.value or "",
),
}
factory = _SCHEMA_TO_RULE.get(s.rule_type)
if factory is None:
+6 -4
View File
@@ -28,7 +28,7 @@ from ledgrab.config import get_config
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.database import Database, freeze_writes
from ledgrab.utils import get_logger
from ledgrab.utils import get_logger, read_upload_capped
logger = get_logger(__name__)
@@ -133,9 +133,11 @@ async def restore_config(
because restore replaces all configuration including secrets).
"""
require_authenticated(auth)
raw = await file.read()
if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets)
raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)")
_MAX_BACKUP_BYTES = 200 * 1024 * 1024 # 200 MB (ZIP may contain assets)
try:
raw = await read_upload_capped(file, _MAX_BACKUP_BYTES)
except ValueError:
raise HTTPException(status_code=413, detail="Backup file too large (max 200 MB)")
if len(raw) < 100:
raise HTTPException(status_code=400, detail="File too small to be a valid backup")
@@ -9,6 +9,7 @@ from ledgrab.api.schemas.color_strip_sources import (
CompositeCSSResponse,
DaylightCSSResponse,
EffectCSSResponse,
GameEventCSSResponse,
GradientCSSResponse,
KeyColorsCSSResponse,
MappedCSSResponse,
@@ -17,7 +18,7 @@ from ledgrab.api.schemas.color_strip_sources import (
PictureAdvancedCSSResponse,
PictureCSSResponse,
ProcessedCSSResponse,
StaticCSSResponse,
SingleColorCSSResponse,
WeatherCSSResponse,
)
from ledgrab.api.schemas.devices import Calibration as CalibrationSchema
@@ -26,22 +27,7 @@ from ledgrab.core.capture.calibration import (
calibration_to_dict,
)
from ledgrab.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ApiInputColorStripSource,
AudioColorStripSource,
CandlelightColorStripSource,
CompositeColorStripSource,
DaylightColorStripSource,
EffectColorStripSource,
GradientColorStripSource,
KeyColorsColorStripSource,
MappedColorStripSource,
MathWaveColorStripSource,
NotificationColorStripSource,
PictureColorStripSource,
ProcessedColorStripSource,
StaticColorStripSource,
WeatherColorStripSource,
_SOURCE_TYPE_MAP as _STORAGE_TYPE_MAP,
)
from ledgrab.storage.picture_source import (
ProcessedPictureSource,
@@ -94,34 +80,46 @@ def _stops_schema(source) -> list[ColorStopSchema] | None:
return None
# Maps storage class → response builder lambda.
# Maps ``source_type`` string → response builder.
#
# Keying by source_type (rather than type(source)) lets the import-time
# coverage check use the storage registry's keys directly, with no
# inversion or duplicate-class handling for legacy aliases.
_RESPONSE_MAP: dict = {
PictureColorStripSource: lambda s, kw: PictureCSSResponse(
"picture": lambda s, kw: PictureCSSResponse(
**kw,
picture_source_id=s.picture_source_id,
smoothing=s.smoothing.to_dict(),
interpolation_mode=s.interpolation_mode,
calibration=_calibration_schema(s),
),
AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse(
"picture_advanced": lambda s, kw: PictureAdvancedCSSResponse(
**kw,
smoothing=s.smoothing.to_dict(),
interpolation_mode=s.interpolation_mode,
calibration=_calibration_schema(s),
),
StaticColorStripSource: lambda s, kw: StaticCSSResponse(
"single_color": lambda s, kw: SingleColorCSSResponse(
**kw,
color=s.color.to_dict(),
animation=s.animation,
),
GradientColorStripSource: lambda s, kw: GradientCSSResponse(
# Legacy alias: pre-rename rows used "static"; the data migration rewrites
# them on first store load but a stale in-flight instance would still
# carry source_type='static' until the next reload.
"static": lambda s, kw: SingleColorCSSResponse(
**kw,
color=s.color.to_dict(),
animation=s.animation,
),
"gradient": lambda s, kw: GradientCSSResponse(
**kw,
stops=_stops_schema(s),
animation=s.animation,
easing=s.easing,
gradient_id=s.gradient_id,
),
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
"effect": lambda s, kw: EffectCSSResponse(
**kw,
effect_type=s.effect_type,
palette=s.palette,
@@ -132,15 +130,15 @@ _RESPONSE_MAP: dict = {
mirror=s.mirror,
custom_palette=s.custom_palette,
),
CompositeColorStripSource: lambda s, kw: CompositeCSSResponse(
"composite": lambda s, kw: CompositeCSSResponse(
**kw,
layers=[dict(layer) for layer in s.layers],
),
MappedColorStripSource: lambda s, kw: MappedCSSResponse(
"mapped": lambda s, kw: MappedCSSResponse(
**kw,
zones=[dict(z) for z in s.zones],
),
AudioColorStripSource: lambda s, kw: AudioCSSResponse(
"audio": lambda s, kw: AudioCSSResponse(
**kw,
visualization_mode=s.visualization_mode,
audio_source_id=s.audio_source_id,
@@ -153,13 +151,13 @@ _RESPONSE_MAP: dict = {
mirror=s.mirror,
beat_decay=s.beat_decay.to_dict(),
),
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
"api_input": lambda s, kw: ApiInputCSSResponse(
**kw,
fallback_color=s.fallback_color.to_dict(),
timeout=s.timeout.to_dict(),
interpolation=s.interpolation,
),
NotificationColorStripSource: lambda s, kw: NotificationCSSResponse(
"notification": lambda s, kw: NotificationCSSResponse(
**kw,
notification_effect=s.notification_effect,
duration_ms=s.duration_ms.to_dict(),
@@ -172,14 +170,14 @@ _RESPONSE_MAP: dict = {
sound_volume=s.sound_volume.to_dict(),
app_sounds=dict(s.app_sounds),
),
DaylightColorStripSource: lambda s, kw: DaylightCSSResponse(
"daylight": lambda s, kw: DaylightCSSResponse(
**kw,
speed=s.speed.to_dict(),
use_real_time=s.use_real_time,
latitude=s.latitude,
longitude=s.longitude,
),
CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse(
"candlelight": lambda s, kw: CandlelightCSSResponse(
**kw,
color=s.color.to_dict(),
intensity=s.intensity.to_dict(),
@@ -188,18 +186,18 @@ _RESPONSE_MAP: dict = {
wind_strength=s.wind_strength.to_dict(),
candle_type=s.candle_type,
),
ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse(
"processed": lambda s, kw: ProcessedCSSResponse(
**kw,
input_source_id=s.input_source_id,
processing_template_id=s.processing_template_id,
),
WeatherColorStripSource: lambda s, kw: WeatherCSSResponse(
"weather": lambda s, kw: WeatherCSSResponse(
**kw,
weather_source_id=s.weather_source_id,
speed=s.speed.to_dict(),
temperature_influence=s.temperature_influence.to_dict(),
),
KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse(
"key_colors": lambda s, kw: KeyColorsCSSResponse(
**kw,
picture_source_id=s.picture_source_id,
rectangles=[r.to_dict() for r in s.rectangles],
@@ -207,28 +205,67 @@ _RESPONSE_MAP: dict = {
smoothing=s.smoothing.to_dict(),
brightness=s.brightness.to_dict(),
),
MathWaveColorStripSource: lambda s, kw: MathWaveCSSResponse(
"math_wave": lambda s, kw: MathWaveCSSResponse(
**kw,
waves=s.waves,
speed=s.speed.to_dict(),
gradient_id=s.gradient_id,
),
"game_event": lambda s, kw: GameEventCSSResponse(
**kw,
game_integration_id=s.game_integration_id,
idle_color=s.idle_color.to_dict(),
event_mappings=[dict(m) for m in s.event_mappings],
),
}
def _assert_response_map_coverage() -> None:
"""Verify _RESPONSE_MAP has a builder for every kind in storage's registry.
Runs at module import. Surfaces missing builders eagerly instead of
letting a request fall through to a silent / wrong response shape.
Contract note
-------------
This check is **symmetric** (``_RESPONSE_MAP keys == storage_kinds``)
because every kind — sharable or not — needs a response shape. The
sister assertion in
``core/processing/color_strip_kinds.py::_assert_stream_kind_coverage``
is asymmetric because sharable kinds construct their streams via a
different path. Adding a new kind requires keeping all three registries
aligned: storage's ``_SOURCE_TYPE_MAP``, this ``_RESPONSE_MAP``, and
either ``STREAM_BUILDERS`` or ``SHARABLE_KINDS``.
"""
storage_kinds = set(_STORAGE_TYPE_MAP.keys())
builder_kinds = set(_RESPONSE_MAP.keys())
missing = storage_kinds - builder_kinds
extra = builder_kinds - storage_kinds
if missing or extra:
problems = []
if missing:
problems.append(f"missing builders for: {sorted(missing)}")
if extra:
problems.append(f"unregistered kinds in _RESPONSE_MAP: {sorted(extra)}")
raise RuntimeError(
"_RESPONSE_MAP is out of sync with storage._SOURCE_TYPE_MAP: " + "; ".join(problems)
)
_assert_response_map_coverage()
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
"""Convert a ColorStripSource to the matching per-type response schema."""
kw = _common_response_kwargs(source, overlay_active)
builder = _RESPONSE_MAP.get(type(source))
builder = _RESPONSE_MAP.get(source.source_type)
if builder is None:
# Fallback: use to_dict() and build a PictureCSSResponse
logger.warning("No response builder for %s, falling back", type(source).__name__)
return PictureCSSResponse(
**kw,
picture_source_id="",
smoothing=0.3,
interpolation_mode="average",
calibration=None,
# Coverage is asserted at import time, so reaching this branch means a
# source was loaded with a source_type that is not registered.
# Surface the bug instead of silently returning a wrong-shaped response.
raise RuntimeError(
f"No CSS response builder registered for source_type "
f"{source.source_type!r} (class={type(source).__name__})"
)
return builder(source, kw)
@@ -29,7 +29,7 @@ router = APIRouter()
_PREVIEW_ALLOWED_TYPES = {
"static",
"single_color",
"gradient",
"effect",
"daylight",
@@ -97,65 +97,65 @@ async def preview_color_strip_ws(
return ColorStripSource.from_dict(config)
def _create_stream(source):
"""Instantiate and start the appropriate stream class for *source*."""
from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
"""Instantiate and start the appropriate stream class for *source*.
Delegates the per-kind dispatch to ``color_strip_kinds.build_stream``
so this preview path and the production ``ColorStripStreamManager``
share a single registry. Per-kind dependencies (CSPT store, audio
stores, weather manager, …) are gathered into a ``StreamDeps`` bag.
FastAPI-DI providers raise ``RuntimeError`` when they aren't wired,
so we resolve each one through ``_safe`` and pass ``None`` on
failure. The per-kind builder will still see a clear error if a
truly-required dep is missing for that kind, but unrelated previews
(e.g. a ``single_color`` preview on a fresh install where the CSPT
store isn't initialized yet) keep working.
"""
from ledgrab.api.dependencies import (
get_audio_processing_template_store,
get_audio_source_store,
get_audio_template_store,
get_cspt_store,
)
from ledgrab.core.processing.color_strip_kinds import StreamDeps, build_stream
def _safe(getter):
try:
return getter()
except RuntimeError as e:
logger.debug("Preview dep not available (%s): %s", getter.__name__, e)
return None
mgr = get_processor_manager()
csm = mgr.color_strip_stream_manager
if source.source_type == "audio":
from ledgrab.api.dependencies import (
get_audio_processing_template_store,
get_audio_source_store,
get_audio_template_store,
)
from ledgrab.core.processing.audio_stream import AudioColorStripStream
# The game-event bus is optional in preview contexts.
try:
from ledgrab.api.dependencies import get_game_event_bus
s = AudioColorStripStream(
source,
mgr.audio_capture_manager,
get_audio_source_store(),
get_audio_template_store(),
get_audio_processing_template_store(),
)
elif source.source_type == "weather":
from ledgrab.core.processing.weather_stream import WeatherColorStripStream
game_event_bus = get_game_event_bus()
except RuntimeError as e:
logger.debug("Preview: no game event bus available: %s", e)
game_event_bus = None
s = WeatherColorStripStream(source, mgr.weather_manager)
elif source.source_type == "game_event":
from ledgrab.core.processing.game_event_stream import GameEventColorStripStream
s = GameEventColorStripStream(source)
try:
from ledgrab.api.dependencies import get_game_event_bus
bus = get_game_event_bus()
except RuntimeError as e:
logger.debug("Preview: no game event bus available: %s", e)
else:
if bus is not None:
s.set_event_bus(bus)
elif source.source_type == "mapped":
from ledgrab.core.processing.mapped_stream import MappedColorStripStream
s = MappedColorStripStream(source, csm)
elif source.source_type == "composite":
from ledgrab.api.dependencies import get_cspt_store
from ledgrab.core.processing.composite_stream import CompositeColorStripStream
s = CompositeColorStripStream(
source, csm, mgr.value_stream_manager, get_cspt_store(), depth=0
)
elif source.source_type == "processed":
from ledgrab.api.dependencies import get_cspt_store
from ledgrab.core.processing.processed_stream import ProcessedColorStripStream
s = ProcessedColorStripStream(source, csm, get_cspt_store())
else:
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls:
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
s = stream_cls(source)
deps = StreamDeps(
css_manager=csm,
value_stream_manager=mgr.value_stream_manager,
cspt_store=_safe(get_cspt_store),
weather_manager=mgr.weather_manager,
audio_capture_manager=mgr.audio_capture_manager,
audio_source_store=_safe(get_audio_source_store),
audio_template_store=_safe(get_audio_template_store),
audio_processing_template_store=_safe(get_audio_processing_template_store),
game_event_bus=game_event_bus,
depth=0,
)
try:
s = build_stream(source, deps)
except ValueError as e:
# Preserve the registry's original detail so the API consumer
# sees which kind was rejected, not just a generic message.
raise ValueError(f"Unsupported preview source_type: {e}") from e
# Inject gradient store for palette resolution
if hasattr(s, "set_gradient_store"):
try:
@@ -428,8 +428,17 @@ async def css_api_input_ws(
continue
elif "bytes" in message:
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
# Binary frame: raw RGBRGB... bytes (3 bytes per LED).
# Cap to a generous upper bound on the LED count — a hostile
# client could otherwise stream 100 MB frames and OOM the
# server before any application logic ran.
raw_bytes = message["bytes"]
_MAX_BINARY_LEDS = 8192
if len(raw_bytes) > _MAX_BINARY_LEDS * 3:
await websocket.send_json(
{"error": f"Binary frame too large (max {_MAX_BINARY_LEDS} LEDs)"}
)
continue
if len(raw_bytes) % 3 != 0:
await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"})
continue
@@ -0,0 +1,270 @@
"""HTTP endpoint routes: CRUD + one-shot test."""
import json
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_http_endpoint_store,
)
from ledgrab.api.schemas.http_endpoints import (
HTTPEndpointCreate,
HTTPEndpointListResponse,
HTTPEndpointResponse,
HTTPEndpointUpdate,
HTTPTestRequest,
HTTPTestResponse,
)
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.http_endpoint import HTTPEndpoint
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.utils import get_logger
from ledgrab.utils.safe_source import safe_request_bounded, validate_polling_url
logger = get_logger(__name__)
router = APIRouter()
def _warn_if_plaintext_token(url: str, auth_token: str, *, action: str) -> None:
"""Log a warning when an auth token would be sent over plaintext http://."""
if auth_token and url.lower().startswith("http://"):
logger.warning(
"HTTP endpoint %s: auth_token will be sent over plaintext http:// to %s. "
"Anyone on the network path can read it. Consider https:// if the "
"target supports TLS.",
action,
url,
)
def _to_response(endpoint: HTTPEndpoint) -> HTTPEndpointResponse:
return HTTPEndpointResponse(
id=endpoint.id,
name=endpoint.name,
url=endpoint.url,
method=endpoint.method,
auth_token_set=bool(endpoint.auth_token),
headers=dict(endpoint.headers),
timeout_s=endpoint.timeout_s,
description=endpoint.description,
tags=endpoint.tags,
icon=getattr(endpoint, "icon", "") or "",
icon_color=getattr(endpoint, "icon_color", "") or "",
created_at=endpoint.created_at,
updated_at=endpoint.updated_at,
)
@router.get(
"/api/v1/http/endpoints",
response_model=HTTPEndpointListResponse,
tags=["HTTP"],
)
async def list_http_endpoints(
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
endpoints = store.get_all_endpoints()
return HTTPEndpointListResponse(
endpoints=[_to_response(e) for e in endpoints],
count=len(endpoints),
)
@router.post(
"/api/v1/http/endpoints",
response_model=HTTPEndpointResponse,
status_code=201,
tags=["HTTP"],
)
async def create_http_endpoint(
data: HTTPEndpointCreate,
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
validate_polling_url(data.url)
_warn_if_plaintext_token(data.url, data.auth_token, action="create")
try:
endpoint = store.create_endpoint(
name=data.name,
url=data.url,
method=data.method,
auth_token=data.auth_token,
headers=data.headers,
timeout_s=data.timeout_s,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("http_endpoint", "created", endpoint.id)
return _to_response(endpoint)
@router.get(
"/api/v1/http/endpoints/{endpoint_id}",
response_model=HTTPEndpointResponse,
tags=["HTTP"],
)
async def get_http_endpoint(
endpoint_id: str,
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
try:
endpoint = store.get_endpoint(endpoint_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
return _to_response(endpoint)
@router.put(
"/api/v1/http/endpoints/{endpoint_id}",
response_model=HTTPEndpointResponse,
tags=["HTTP"],
)
async def update_http_endpoint(
endpoint_id: str,
data: HTTPEndpointUpdate,
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
if data.url is not None:
validate_polling_url(data.url)
final_url = data.url
final_token = data.auth_token
if final_url is None or final_token is None:
try:
existing = store.get_endpoint(endpoint_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
if final_url is None:
final_url = existing.url
if final_token is None:
final_token = existing.auth_token
_warn_if_plaintext_token(final_url, final_token, action="update")
try:
endpoint = store.update_endpoint(
endpoint_id,
name=data.name,
url=data.url,
method=data.method,
auth_token=data.auth_token,
headers=data.headers,
timeout_s=data.timeout_s,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("http_endpoint", "updated", endpoint.id)
return _to_response(endpoint)
@router.delete(
"/api/v1/http/endpoints/{endpoint_id}",
status_code=204,
tags=["HTTP"],
)
async def delete_http_endpoint(
endpoint_id: str,
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
try:
store.delete_endpoint(endpoint_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
fire_entity_event("http_endpoint", "deleted", endpoint_id)
async def _run_http_test(
method: str,
url: str,
headers: dict[str, str],
timeout_s: float,
) -> HTTPTestResponse:
"""Shared one-shot fetch + response shaping for both test endpoints."""
try:
status, body_bytes, error = await safe_request_bounded(
method, url, headers=headers, timeout=timeout_s
)
except HTTPException:
raise
except Exception as exc:
return HTTPTestResponse(success=False, error=f"Unexpected error: {type(exc).__name__}")
if error and status == 0:
return HTTPTestResponse(success=False, error=error)
try:
body_text = body_bytes.decode("utf-8")
except UnicodeDecodeError:
body_text = body_bytes.decode("utf-8", errors="replace")
try:
body_json = json.loads(body_text) if body_text else None
except (json.JSONDecodeError, ValueError):
body_json = None
preview = body_text[:500] if body_text else None
is_success = 200 <= status < 300
return HTTPTestResponse(
success=is_success,
status_code=status,
body_preview=preview,
body_json=body_json,
error=None if is_success else f"HTTP {status}",
)
@router.post(
"/api/v1/http/endpoints/test",
response_model=HTTPTestResponse,
tags=["HTTP"],
)
async def test_http_endpoint(
data: HTTPTestRequest,
_auth: AuthRequired,
):
"""One-shot fetch to validate URL + auth before saving."""
headers = dict(data.headers)
if data.auth_token and not any(k.lower() == "authorization" for k in headers):
headers["Authorization"] = f"Bearer {data.auth_token}"
return await _run_http_test(data.method, data.url, headers, data.timeout_s)
@router.post(
"/api/v1/http/endpoints/{endpoint_id}/test",
response_model=HTTPTestResponse,
tags=["HTTP"],
)
async def test_saved_http_endpoint(
endpoint_id: str,
_auth: AuthRequired,
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
):
"""Run the stored endpoint configuration (URL + auth + headers + timeout).
Useful for the "test" button on the endpoint card: avoids the user
having to open the editor and re-enter the auth token (which is
never returned to the client).
"""
try:
endpoint = store.get_endpoint(endpoint_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
return await _run_http_test(
endpoint.method,
endpoint.url,
endpoint.build_request_headers(),
endpoint.timeout_s,
)
+48 -17
View File
@@ -175,26 +175,57 @@ def _validate_color_value_source(
)
def _target_to_response(target) -> OutputTargetResponse:
"""Convert any OutputTarget to the appropriate typed response."""
if isinstance(target, WledOutputTarget):
return _led_target_to_response(target)
elif isinstance(target, HALightOutputTarget):
return _ha_light_target_to_response(target)
elif isinstance(target, Z2MLightOutputTarget):
return _z2m_light_target_to_response(target)
else:
# Fallback for unknown types — use LED response with defaults
return LedOutputTargetResponse(
id=target.id,
name=target.name,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
_TARGET_RESPONSE_BUILDERS: dict = {
WledOutputTarget: _led_target_to_response,
HALightOutputTarget: _ha_light_target_to_response,
Z2MLightOutputTarget: _z2m_light_target_to_response,
}
def _assert_target_response_coverage() -> None:
"""Verify the response registry covers every concrete OutputTarget subclass.
Runs at module import. Surfaces a missing builder eagerly instead of
letting a request fall through to the previous silent fallback (which
used to return a defaults-filled LedOutputTargetResponse and quietly
misshape the payload for unknown target types).
"""
expected = {WledOutputTarget, HALightOutputTarget, Z2MLightOutputTarget}
registered = set(_TARGET_RESPONSE_BUILDERS.keys())
missing = expected - registered
extra = registered - expected
if missing or extra:
problems = []
if missing:
problems.append(f"missing builders: {sorted(c.__name__ for c in missing)}")
if extra:
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
raise RuntimeError(
"_TARGET_RESPONSE_BUILDERS is out of sync with the OutputTarget "
"subclass set: " + "; ".join(problems)
)
_assert_target_response_coverage()
def _target_to_response(target) -> OutputTargetResponse:
"""Convert any OutputTarget to the appropriate typed response.
Dispatches via :data:`_TARGET_RESPONSE_BUILDERS` keyed by concrete
subclass. Raises ``RuntimeError`` for an unregistered subclass —
coverage is asserted at import, so this should never fire in
practice; if it does, the storage layer added a new OutputTarget
subclass without a matching response builder here.
"""
builder = _TARGET_RESPONSE_BUILDERS.get(type(target))
if builder is None:
raise RuntimeError(
f"No response builder registered for OutputTarget subclass " f"{type(target).__name__}"
)
return builder(target)
# ===== CRUD ENDPOINTS =====
@@ -23,6 +23,7 @@ from ledgrab.api.schemas.value_sources import (
DaylightValueSourceResponse,
GradientMapValueSourceResponse,
HAEntityValueSourceResponse,
HTTPValueSourceResponse,
StaticColorValueSourceResponse,
StaticValueSourceResponse,
SystemMetricsValueSourceResponse,
@@ -41,6 +42,7 @@ from ledgrab.storage.value_source import (
DaylightValueSource,
GradientMapValueSource,
HAEntityValueSource,
HTTPValueSource,
StaticColorValueSource,
StaticValueSource,
SystemMetricsValueSource,
@@ -213,6 +215,22 @@ _RESPONSE_MAP = {
poll_interval=s.poll_interval,
smoothing=s.smoothing,
),
HTTPValueSource: lambda s: HTTPValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
http_endpoint_id=s.http_endpoint_id,
json_path=s.json_path,
interval_s=s.interval_s,
min_value=s.min_value,
max_value=s.max_value,
smoothing=s.smoothing,
),
}
+14 -1
View File
@@ -30,6 +30,9 @@ _RATE_WINDOW = 60.0 # seconds
_rate_hits: dict[str, list[float]] = defaultdict(list)
_RATE_HITS_HARD_CAP = 1024
def _check_rate_limit(client_ip: str) -> None:
"""Raise 429 if *client_ip* exceeded the webhook rate limit."""
now = time.time()
@@ -44,11 +47,21 @@ def _check_rate_limit(client_ip: str) -> None:
)
_rate_hits[client_ip].append(now)
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth.
if len(_rate_hits) > 100:
stale = [ip for ip, ts in _rate_hits.items() if not ts or ts[-1] < window_start]
for ip in stale:
del _rate_hits[ip]
# Hard cap as a final defence against an attacker spraying many distinct
# X-Forwarded-For values to drive memory growth past the soft cleanup
# threshold. Drop the oldest-touched IPs (by their latest timestamp).
if len(_rate_hits) > _RATE_HITS_HARD_CAP:
ordered = sorted(
_rate_hits.items(),
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
)
for ip, _ in ordered[: len(ordered) - _RATE_HITS_HARD_CAP]:
_rate_hits.pop(ip, None)
class WebhookPayload(BaseModel):
@@ -46,6 +46,24 @@ class RuleSchema(BaseModel):
None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
)
# HTTP poll rule fields
value_source_id: Optional[str] = Field(
None,
description=(
"Value source ID (for http_poll rule). The referenced "
"ValueSource must be of source_type='http'."
),
)
operator: Optional[str] = Field(
None,
description=(
"Comparison operator for http_poll rule: "
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
),
)
value: Optional[str] = Field(
None, description="Expected value (for http_poll rule; ignored for 'exists')"
)
# Backward-compatible alias
@@ -122,9 +122,9 @@ class PictureAdvancedCSSResponse(_CSSResponseBase):
calibration: Optional[Calibration] = Field(None, description="LED calibration")
class StaticCSSResponse(_CSSResponseBase):
source_type: Literal["static"] = "static"
color: Any = Field(description="Static RGB color")
class SingleColorCSSResponse(_CSSResponseBase):
source_type: Literal["single_color"] = "single_color"
color: Any = Field(description="Solid RGB color")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
@@ -240,11 +240,18 @@ class MathWaveCSSResponse(_CSSResponseBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSResponse(_CSSResponseBase):
source_type: Literal["game_event"] = "game_event"
game_integration_id: str = Field(description="Game integration entity ID")
idle_color: Any = Field(description="Idle RGB color (bindable)")
event_mappings: List[dict] = Field(default_factory=list, description="Event-to-effect mappings")
ColorStripSourceResponse = Annotated[
Union[
Annotated[PictureCSSResponse, Tag("picture")],
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
Annotated[StaticCSSResponse, Tag("static")],
Annotated[SingleColorCSSResponse, Tag("single_color")],
Annotated[GradientCSSResponse, Tag("gradient")],
Annotated[EffectCSSResponse, Tag("effect")],
Annotated[CompositeCSSResponse, Tag("composite")],
@@ -258,6 +265,7 @@ ColorStripSourceResponse = Annotated[
Annotated[WeatherCSSResponse, Tag("weather")],
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
Annotated[MathWaveCSSResponse, Tag("math_wave")],
Annotated[GameEventCSSResponse, Tag("game_event")],
],
Discriminator("source_type"),
]
@@ -303,9 +311,9 @@ class PictureAdvancedCSSCreate(_CSSCreateBase):
calibration: Optional[Calibration] = Field(None, description="LED calibration")
class StaticCSSCreate(_CSSCreateBase):
source_type: Literal["static"] = "static"
color: Any = Field(default=None, description="Static RGB color [R,G,B]")
class SingleColorCSSCreate(_CSSCreateBase):
source_type: Literal["single_color"] = "single_color"
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
@@ -434,11 +442,18 @@ class MathWaveCSSCreate(_CSSCreateBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSCreate(_CSSCreateBase):
source_type: Literal["game_event"] = "game_event"
game_integration_id: Optional[str] = Field(None, description="Game integration entity ID")
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
event_mappings: Optional[List[dict]] = Field(None, description="Event-to-effect mappings")
ColorStripSourceCreate = Annotated[
Union[
Annotated[PictureCSSCreate, Tag("picture")],
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
Annotated[StaticCSSCreate, Tag("static")],
Annotated[SingleColorCSSCreate, Tag("single_color")],
Annotated[GradientCSSCreate, Tag("gradient")],
Annotated[EffectCSSCreate, Tag("effect")],
Annotated[CompositeCSSCreate, Tag("composite")],
@@ -452,6 +467,7 @@ ColorStripSourceCreate = Annotated[
Annotated[WeatherCSSCreate, Tag("weather")],
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
Annotated[MathWaveCSSCreate, Tag("math_wave")],
Annotated[GameEventCSSCreate, Tag("game_event")],
],
Discriminator("source_type"),
]
@@ -497,9 +513,9 @@ class PictureAdvancedCSSUpdate(_CSSUpdateBase):
calibration: Optional[Calibration] = Field(None, description="LED calibration")
class StaticCSSUpdate(_CSSUpdateBase):
source_type: Literal["static"] = "static"
color: Any = Field(default=None, description="Static RGB color [R,G,B]")
class SingleColorCSSUpdate(_CSSUpdateBase):
source_type: Literal["single_color"] = "single_color"
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
@@ -626,11 +642,18 @@ class MathWaveCSSUpdate(_CSSUpdateBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSUpdate(_CSSUpdateBase):
source_type: Literal["game_event"] = "game_event"
game_integration_id: Optional[str] = Field(None, description="Game integration entity ID")
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
event_mappings: Optional[List[dict]] = Field(None, description="Event-to-effect mappings")
ColorStripSourceUpdate = Annotated[
Union[
Annotated[PictureCSSUpdate, Tag("picture")],
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
Annotated[StaticCSSUpdate, Tag("static")],
Annotated[SingleColorCSSUpdate, Tag("single_color")],
Annotated[GradientCSSUpdate, Tag("gradient")],
Annotated[EffectCSSUpdate, Tag("effect")],
Annotated[CompositeCSSUpdate, Tag("composite")],
@@ -644,6 +667,7 @@ ColorStripSourceUpdate = Annotated[
Annotated[WeatherCSSUpdate, Tag("weather")],
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
Annotated[GameEventCSSUpdate, Tag("game_event")],
],
Discriminator("source_type"),
]
@@ -0,0 +1,166 @@
"""HTTP endpoint schemas (CRUD + one-shot test)."""
import re
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional
from urllib.parse import urlparse
from pydantic import BaseModel, Field, field_validator
# RFC 7230 token chars for header names + reject any control character in values.
_HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
def _validate_headers(value: Dict[str, str]) -> Dict[str, str]:
"""Reject header names/values that could enable CRLF injection."""
if value is None:
return {}
cleaned: Dict[str, str] = {}
for name, raw in value.items():
if not isinstance(name, str) or not isinstance(raw, str):
raise ValueError(f"Header name/value must be strings: {name!r}")
if not _HEADER_NAME_RE.match(name):
raise ValueError(f"Invalid HTTP header name: {name!r}")
if _HEADER_CONTROL_CHARS_RE.search(raw):
raise ValueError(
f"Invalid HTTP header value for {name!r} (contains control characters)"
)
cleaned[name] = raw
return cleaned
def _validate_url(value: str) -> str:
"""Reject URLs that embed ``user:pass@`` so credentials can't leak
into server logs (e.g. via the plaintext-token warning helper).
Schemes + IP-block checks are enforced later by
:func:`ledgrab.utils.safe_source.validate_polling_url`.
"""
if not value:
return value
parsed = urlparse(value)
if parsed.username is not None or parsed.password is not None:
raise ValueError(
"URL must not embed credentials (user:pass@). "
"Use the auth_token field or a custom Authorization header instead."
)
return value
class HTTPEndpointCreate(BaseModel):
"""Request to create an HTTP endpoint."""
name: str = Field(min_length=1, max_length=100)
url: str = Field(min_length=1, description="http or https URL")
method: Literal["GET", "HEAD"] = "GET"
auth_token: str = Field(
default="",
description=(
"Optional bearer token — sent as 'Authorization: Bearer <token>'. "
"Add a custom Authorization entry in `headers` to override."
),
)
headers: Dict[str, str] = Field(default_factory=dict)
timeout_s: float = Field(default=10.0, gt=0)
description: Optional[str] = Field(None, max_length=500)
tags: List[str] = Field(default_factory=list)
icon: Optional[str] = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32)
@field_validator("headers")
@classmethod
def _check_headers(cls, value):
return _validate_headers(value)
@field_validator("url")
@classmethod
def _check_url(cls, value):
return _validate_url(value)
class HTTPEndpointUpdate(BaseModel):
"""Request to update an HTTP endpoint.
All fields optional — ``None`` keeps the existing value. Sending an
empty string for ``auth_token`` CLEARS the stored token; omit the
field (or send ``null``) to keep it.
"""
name: Optional[str] = Field(None, min_length=1, max_length=100)
url: Optional[str] = Field(None, min_length=1)
method: Optional[Literal["GET", "HEAD"]] = None
auth_token: Optional[str] = Field(None, description="null = keep existing; '' = clear.")
headers: Optional[Dict[str, str]] = None
timeout_s: Optional[float] = Field(None, gt=0)
description: Optional[str] = Field(None, max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32)
@field_validator("headers")
@classmethod
def _check_headers(cls, value):
if value is None:
return None
return _validate_headers(value)
@field_validator("url")
@classmethod
def _check_url(cls, value):
if value is None:
return None
return _validate_url(value)
class HTTPEndpointResponse(BaseModel):
"""HTTP endpoint response. Note: ``auth_token`` is NEVER returned —
use ``auth_token_set`` to know whether one is configured."""
id: str
name: str
url: str
method: str
auth_token_set: bool = False
headers: Dict[str, str] = Field(default_factory=dict)
timeout_s: float
description: Optional[str] = None
tags: List[str] = Field(default_factory=list)
icon: Optional[str] = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32)
created_at: datetime
updated_at: datetime
class HTTPEndpointListResponse(BaseModel):
endpoints: List[HTTPEndpointResponse]
count: int
class HTTPTestRequest(BaseModel):
"""One-shot test request to validate URL + auth before saving."""
url: str
method: Literal["GET", "HEAD"] = "GET"
auth_token: str = ""
headers: Dict[str, str] = Field(default_factory=dict)
timeout_s: float = Field(default=10.0, gt=0)
@field_validator("headers")
@classmethod
def _check_headers(cls, value):
return _validate_headers(value)
@field_validator("url")
@classmethod
def _check_url(cls, value):
return _validate_url(value)
class HTTPTestResponse(BaseModel):
success: bool
status_code: Optional[int] = None
body_preview: Optional[str] = Field(None, description="First 500 chars of the body")
body_json: Any = None
error: Optional[str] = None
@@ -151,6 +151,17 @@ class SystemMetricsValueSourceResponse(_ValueSourceResponseBase):
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
class HTTPValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["http"] = "http"
return_type: Literal["float"] = "float"
http_endpoint_id: str = Field(description="HTTP endpoint ID")
json_path: str = Field(description="Dot-path into the response body")
interval_s: int = Field(description="Polling cadence (seconds)")
min_value: float = Field(description="Raw value mapped to output 0.0")
max_value: float = Field(description="Raw value mapped to output 1.0")
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
ValueSourceResponse = Annotated[
Union[
Annotated[StaticValueSourceResponse, Tag("static")],
@@ -166,6 +177,7 @@ ValueSourceResponse = Annotated[
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")],
Annotated[HTTPValueSourceResponse, Tag("http")],
],
Discriminator("source_type"),
]
@@ -310,6 +322,16 @@ class SystemMetricsValueSourceCreate(_ValueSourceCreateBase):
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
class HTTPValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["http"] = "http"
http_endpoint_id: str = Field(description="HTTP endpoint ID")
json_path: str = Field("", description="Dot-path into the response (empty = raw body text)")
interval_s: int = Field(60, description="Polling cadence (seconds)", ge=1)
min_value: float = Field(0.0, description="Raw value mapped to output 0.0")
max_value: float = Field(100.0, description="Raw value mapped to output 1.0")
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
ValueSourceCreate = Annotated[
Union[
Annotated[StaticValueSourceCreate, Tag("static")],
@@ -325,6 +347,7 @@ ValueSourceCreate = Annotated[
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")],
Annotated[HTTPValueSourceCreate, Tag("http")],
],
Discriminator("source_type"),
]
@@ -463,6 +486,16 @@ class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["http"] = "http"
http_endpoint_id: Optional[str] = Field(None, description="HTTP endpoint ID")
json_path: Optional[str] = Field(None, description="Dot-path into the response")
interval_s: Optional[int] = Field(None, description="Polling cadence (seconds)", ge=1)
min_value: Optional[float] = Field(None, description="Raw value mapped to 0.0")
max_value: Optional[float] = Field(None, description="Raw value mapped to 1.0")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
ValueSourceUpdate = Annotated[
Union[
Annotated[StaticValueSourceUpdate, Tag("static")],
@@ -478,6 +511,7 @@ ValueSourceUpdate = Annotated[
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")],
Annotated[HTTPValueSourceUpdate, Tag("http")],
],
Discriminator("source_type"),
]
@@ -2,8 +2,9 @@
import asyncio
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Dict, Optional, Set
from typing import Callable, Dict, Optional, Set
from ledgrab.core.automations.platform_detector import PlatformDetector
from ledgrab.storage.automation import (
@@ -11,6 +12,7 @@ from ledgrab.storage.automation import (
Automation,
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
MQTTRule,
Rule,
StartupRule,
@@ -25,6 +27,53 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__)
@dataclass(frozen=True)
class _RuleEvalContext:
"""Per-tick environment passed to every rule handler.
Bundles all the cross-cutting state the various ``_evaluate_*``
handlers need so adding a new handler does not require widening
``_evaluate_rule``'s parameter list. ``frozen=True`` guards against
a handler mutating its inputs.
"""
running_procs: Set[str]
topmost_proc: Optional[str]
topmost_fullscreen: bool
fullscreen_procs: Set[str]
idle_seconds: Optional[float]
display_state: Optional[str]
def _apply_operator(operator: str, extracted, expected: str) -> bool:
"""Compare *extracted* against *expected* using *operator*.
String operators (equals, not_equals, contains, regex) coerce the
extracted value to str. Numeric operators (gt, lt) coerce both sides
to float and return False on parse failure.
"""
if operator == "equals":
return str(extracted) == expected
if operator == "not_equals":
return str(extracted) != expected
if operator == "contains":
return expected in str(extracted)
if operator == "regex":
try:
return bool(re.search(expected, str(extracted)))
except re.error as exc:
logger.debug("HTTP poll rule regex error: %s", exc)
return False
if operator in ("gt", "lt"):
try:
lhs = float(extracted)
rhs = float(expected)
except (TypeError, ValueError):
return False
return lhs > rhs if operator == "gt" else lhs < rhs
return False
class AutomationEngine:
"""Evaluates automation rules and activates/deactivates scene presets."""
@@ -38,12 +87,16 @@ class AutomationEngine:
device_store=None,
ha_manager=None,
mqtt_manager=None,
value_stream_manager=None,
value_source_store=None,
):
self._store = automation_store
self._manager = processor_manager
self._poll_interval = poll_interval
self._detector = PlatformDetector()
self._mqtt_manager = mqtt_manager
self._value_stream_manager = value_stream_manager
self._value_source_store = value_source_store
self._scene_preset_store = scene_preset_store
self._target_store = target_store
self._device_store = device_store
@@ -65,12 +118,15 @@ class AutomationEngine:
self._ha_acquired: Set[str] = set()
# MQTT source IDs currently acquired by the engine
self._mqtt_acquired: Set[str] = set()
# Value source IDs currently acquired by the engine (for HTTPPollRule)
self._value_sources_acquired: Set[str] = set()
async def start(self) -> None:
if self._task is not None:
return
await self._sync_ha_runtimes()
await self._sync_mqtt_runtimes()
self._sync_value_stream_refs()
self._task = asyncio.create_task(self._poll_loop())
logger.info("Automation engine started")
@@ -94,6 +150,8 @@ class AutomationEngine:
await self._release_all_ha_runtimes()
# Release all MQTT runtimes
await self._release_all_mqtt_runtimes()
# Release all value-stream refs held for HTTPPollRule evaluation
self._release_all_value_stream_refs()
logger.info("Automation engine stopped")
@@ -183,6 +241,53 @@ class AutomationEngine:
logger.warning("Failed to release MQTT runtime %s: %s", source_id, e)
self._mqtt_acquired = set()
def _get_needed_value_sources(self) -> Set[str]:
"""Collect value source IDs referenced by enabled HTTPPollRule rules."""
needed: Set[str] = set()
if self._value_stream_manager is None:
return needed
for a in self._store.get_all_automations():
if a.enabled:
for r in a.rules:
if isinstance(r, HTTPPollRule) and r.value_source_id:
needed.add(r.value_source_id)
return needed
def _sync_value_stream_refs(self) -> None:
"""Acquire/release ValueStreams to keep HTTPPollRule sources polling.
Mirrors the HA/MQTT sync pattern, but talks to ``ValueStreamManager``
(which is sync). Acquiring a stream both starts its background poll
task and pins the ref count; releasing decrements.
"""
if self._value_stream_manager is None:
return
needed = self._get_needed_value_sources()
for vs_id in self._value_sources_acquired - needed:
try:
self._value_stream_manager.release(vs_id)
logger.debug("Released value stream for automation: %s", vs_id)
except Exception as e:
logger.warning("Failed to release value stream %s: %s", vs_id, e)
for vs_id in needed - self._value_sources_acquired:
try:
self._value_stream_manager.acquire(vs_id)
logger.debug("Acquired value stream for automation: %s", vs_id)
except Exception as e:
logger.warning("Failed to acquire value stream %s: %s", vs_id, e)
self._value_sources_acquired = needed
def _release_all_value_stream_refs(self) -> None:
"""Release all ValueStreams held for HTTPPollRule evaluation."""
if self._value_stream_manager is None:
return
for vs_id in self._value_sources_acquired:
try:
self._value_stream_manager.release(vs_id)
except Exception as e:
logger.warning("Failed to release value stream %s: %s", vs_id, e)
self._value_sources_acquired = set()
async def _poll_loop(self) -> None:
try:
while True:
@@ -198,6 +303,7 @@ class AutomationEngine:
async def _evaluate_all(self) -> None:
await self._sync_ha_runtimes()
await self._sync_mqtt_runtimes()
self._sync_value_stream_refs()
async with self._eval_lock:
await self._evaluate_all_locked()
@@ -337,6 +443,12 @@ class AutomationEngine:
return all(results)
return any(results) # "or" is default
# Per-rule-type handlers. Built once at class-definition time (see
# ``_RULE_HANDLERS`` below) so the dispatch dict is not rebuilt on every
# tick the way the old inline body used to. Each handler signature is
# ``(self, rule, ctx: _RuleEvalContext) -> bool``.
_RULE_HANDLERS: "Dict[type, Callable[..., bool]]"
def _evaluate_rule(
self,
rule: Rule,
@@ -347,22 +459,63 @@ class AutomationEngine:
idle_seconds: Optional[float],
display_state: Optional[str],
) -> bool:
dispatch = {
StartupRule: lambda r: True,
ApplicationRule: lambda r: self._evaluate_app_rule(
r, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
),
TimeOfDayRule: lambda r: self._evaluate_time_of_day(r),
SystemIdleRule: lambda r: self._evaluate_idle(r, idle_seconds),
DisplayStateRule: lambda r: self._evaluate_display_state(r, display_state),
MQTTRule: lambda r: self._evaluate_mqtt(r),
WebhookRule: lambda r: self._webhook_states.get(r.token, False),
HomeAssistantRule: lambda r: self._evaluate_home_assistant(r),
}
handler = dispatch.get(type(rule))
ctx = _RuleEvalContext(
running_procs=running_procs,
topmost_proc=topmost_proc,
topmost_fullscreen=topmost_fullscreen,
fullscreen_procs=fullscreen_procs,
idle_seconds=idle_seconds,
display_state=display_state,
)
handler = self._RULE_HANDLERS.get(type(rule))
if handler is None:
# Coverage of ``_RULE_HANDLERS`` is asserted at module import,
# so reaching this branch means a Rule subclass slipped past
# the assertion (e.g. a hand-built test instance). Log loudly
# and fall back to the previous "treat as inactive" semantics.
logger.warning(
"No handler registered for rule type %s — treating as inactive",
type(rule).__name__,
)
return False
return handler(rule)
return handler(self, rule, ctx)
# -- Per-rule-type handlers --
# Bound to ``self`` via ``_RULE_HANDLERS`` lookup; each signature is
# ``(self, rule, ctx: _RuleEvalContext) -> bool``.
def _handle_startup(self, rule: StartupRule, ctx: _RuleEvalContext) -> bool:
return True
def _handle_application(self, rule: ApplicationRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_app_rule(
rule,
ctx.running_procs,
ctx.topmost_proc,
ctx.topmost_fullscreen,
ctx.fullscreen_procs,
)
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_time_of_day(rule)
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_idle(rule, ctx.idle_seconds)
def _handle_display_state(self, rule: DisplayStateRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_display_state(rule, ctx.display_state)
def _handle_mqtt(self, rule: MQTTRule, ctx: _RuleEvalContext) -> bool:
return self._evaluate_mqtt(rule)
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
return self._webhook_states.get(rule.token, False)
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 _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
@@ -436,6 +589,25 @@ class AutomationEngine:
logger.debug("HA rule regex error: %s", e)
return False
def _evaluate_http_poll(self, rule: HTTPPollRule) -> bool:
"""Evaluate an HTTPPollRule by reading the referenced value source.
The value source (HTTPValueSource → HTTPValueStream) handles the
actual polling + JSON extraction; the rule only compares the
already-extracted raw value to ``rule.value`` via ``operator``.
"""
if self._value_stream_manager is None or not rule.value_source_id:
return False
stream = self._value_stream_manager.peek(rule.value_source_id)
if stream is None or not hasattr(stream, "get_raw_value"):
return False
raw = stream.get_raw_value()
if rule.operator == "exists":
return raw is not None
if raw is None:
return False
return _apply_operator(rule.operator, raw, rule.value)
def _evaluate_app_rule(
self,
rule: ApplicationRule,
@@ -636,3 +808,57 @@ class AutomationEngine:
"""Deactivate an automation immediately (used when disabling/deleting)."""
if automation_id in self._active_automations:
await self._deactivate_automation(automation_id)
# Bind the per-rule-type handler table once after the class is fully defined.
# This replaces the per-call dict-rebuild that the inline ``_evaluate_rule``
# used to do and gives us a single place to assert coverage against the
# Rule subclass set imported from storage.
AutomationEngine._RULE_HANDLERS = {
StartupRule: AutomationEngine._handle_startup,
ApplicationRule: AutomationEngine._handle_application,
TimeOfDayRule: AutomationEngine._handle_time_of_day,
SystemIdleRule: AutomationEngine._handle_system_idle,
DisplayStateRule: AutomationEngine._handle_display_state,
MQTTRule: AutomationEngine._handle_mqtt,
WebhookRule: AutomationEngine._handle_webhook,
HomeAssistantRule: AutomationEngine._handle_home_assistant,
HTTPPollRule: AutomationEngine._handle_http_poll,
}
def _assert_rule_handler_coverage() -> None:
"""Every concrete Rule subclass imported by this module must have a handler.
Runs at module import so a new Rule subclass added without an
accompanying ``_handle_*`` method + ``_RULE_HANDLERS`` entry fails the
server boot loudly instead of silently being dropped on the floor by
``_evaluate_rule``'s "no handler → False" fallback.
"""
expected = {
StartupRule,
ApplicationRule,
TimeOfDayRule,
SystemIdleRule,
DisplayStateRule,
MQTTRule,
WebhookRule,
HomeAssistantRule,
HTTPPollRule,
}
registered = set(AutomationEngine._RULE_HANDLERS.keys())
missing = expected - registered
extra = registered - expected
if missing or extra:
problems = []
if missing:
problems.append(f"missing handlers: {sorted(c.__name__ for c in missing)}")
if extra:
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
raise RuntimeError(
"AutomationEngine._RULE_HANDLERS is out of sync with imported Rule subclasses: "
+ "; ".join(problems)
)
_assert_rule_handler_coverage()
+15 -169
View File
@@ -5,6 +5,10 @@ from typing import Dict, List, Literal, Set, Tuple
import numpy as np
from ledgrab.core.capture.edge_interpolation import (
average_edge_to_leds,
fallback_edge_to_leds,
)
from ledgrab.core.capture.screen_capture import (
BorderPixels,
calculate_average_color,
@@ -404,107 +408,17 @@ class PixelMapper:
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
) -> np.ndarray:
"""Per-LED color mapping for median/dominant modes. Returns (led_count, 3) uint8."""
if edge_name in ("top", "bottom"):
edge_len = edge_pixels.shape[1]
else:
edge_len = edge_pixels.shape[0]
step = edge_len / led_count
result = np.empty((led_count, 3), dtype=np.uint8)
for i in range(led_count):
start = int(i * step)
end = max(start + 1, int((i + 1) * step))
end = min(end, edge_len)
if edge_name in ("top", "bottom"):
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
color = self._calc_color(segment)
result[i] = color
return result
return fallback_edge_to_leds(edge_pixels, edge_name, led_count, self._calc_color)
def _map_edge_average(
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
) -> np.ndarray:
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8.
Uses pre-allocated cumsum/mean buffers AND pre-allocated output
buffers (lazy-initialized per edge). All per-frame numpy ops write
in-place — zero allocations on the hot path.
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
the shared kernel handles all allocations on first use.
"""
if edge_name in ("top", "bottom"):
axis = 0
edge_len = edge_pixels.shape[1]
else:
axis = 1
edge_len = edge_pixels.shape[0]
# Lazy-init / resize per-edge scratch buffers.
# float32 is sufficient: max cumsum value is edge_len * 255 (≈2M @ 8K
# screens) which fits exactly in float32's 24-bit mantissa. Halves
# memory bandwidth on the hot reduction.
cache = self._edge_cache.get(edge_name)
if cache is None or cache[0] != edge_len or cache[1] != led_count:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
np.minimum(boundaries, edge_len, out=boundaries)
starts = boundaries[:-1]
ends = boundaries[1:]
lengths = (ends - starts).reshape(-1, 1).astype(np.float32)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float32)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float32)
sums_buf = np.empty((led_count, 3), dtype=np.float32)
starts_buf = np.empty((led_count, 3), dtype=np.float32)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
cache = (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
self._edge_cache[edge_name] = cache
(
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = cache
# Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
# Cumsum into pre-allocated buffer (cumsum_buf[0] left at 0 from init)
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
# segment_sums = cumsum_buf[ends] - cumsum_buf[starts] — but each
# fancy-index expression allocates. np.take with ``out=`` writes
# directly into our pre-allocated scratch.
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
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")
return out_uint8
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, edge_name)
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors.
@@ -669,64 +583,12 @@ class AdvancedPixelMapper:
led_count: int,
cache_key: int,
) -> np.ndarray:
"""Vectorized average-color mapping (same algo as PixelMapper)."""
if edge_name in ("top", "bottom"):
axis = 0
edge_len = edge_pixels.shape[1]
else:
axis = 1
edge_len = edge_pixels.shape[0]
"""Vectorized average-color mapping; delegates to the shared kernel.
cache = self._edge_cache.get(cache_key)
if cache is None or cache[0] != edge_len or cache[1] != led_count:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
np.minimum(boundaries, edge_len, out=boundaries)
starts = boundaries[:-1]
ends = boundaries[1:]
lengths = (ends - starts).reshape(-1, 1).astype(np.float32)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float32)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float32)
sums_buf = np.empty((led_count, 3), dtype=np.float32)
starts_buf = np.empty((led_count, 3), dtype=np.float32)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
cache = (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
self._edge_cache[cache_key] = cache
(
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = cache
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
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")
return out_uint8
``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)
def _map_edge_fallback(
self,
@@ -734,24 +596,8 @@ class AdvancedPixelMapper:
edge_name: str,
led_count: int,
) -> np.ndarray:
"""Per-LED color mapping for median/dominant modes."""
if edge_name in ("top", "bottom"):
edge_len = edge_pixels.shape[1]
else:
edge_len = edge_pixels.shape[0]
step = edge_len / led_count
result = np.empty((led_count, 3), dtype=np.uint8)
for i in range(led_count):
start = int(i * step)
end = max(start + 1, int((i + 1) * step))
end = min(end, edge_len)
if edge_name in ("top", "bottom"):
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
result[i] = self._calc_color(segment)
return result
"""Per-LED color mapping for median/dominant modes; delegates to shared kernel."""
return fallback_edge_to_leds(edge_pixels, edge_name, led_count, self._calc_color)
def map_lines_to_leds(self, frames: Dict[str, np.ndarray]) -> np.ndarray:
"""Map multi-source frames to LED colors using calibration lines.
@@ -0,0 +1,163 @@
"""Shared edge-to-LED interpolation kernels for PixelMapper variants.
``PixelMapper`` and ``AdvancedPixelMapper`` in ``calibration.py`` historically
carried two byte-for-byte copies of:
* the fast vectorised "average across each LED segment" path
(``_map_edge_average``) — ~80 lines of buffer-allocation + cumsum tricks; and
* the per-LED-loop "median / dominant colour" path (``_map_edge_fallback``).
Lifting both kernels into pure functions removes the duplication and
keeps the algorithms in one place. Each mapper owns its own scratch-buffer
cache (keyed differently in the two cases — see callers); the functions
accept that cache as an in/out dict so allocations still happen once per
(edge_len, led_count) pair.
These functions intentionally do NOT touch the mappers' state beyond what
the callers pass in, so they are trivially testable in isolation.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Hashable, Tuple
import numpy as np
# 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.
_CacheEntry = Tuple[
int, # edge_len
int, # led_count
np.ndarray, # starts (int64, shape (led_count,))
np.ndarray, # ends (int64, shape (led_count,))
np.ndarray, # lengths (float32, shape (led_count, 1))
np.ndarray, # cumsum_buf (float32, shape (edge_len + 1, 3))
np.ndarray, # edge_1d_buf (float32, shape (edge_len, 3))
np.ndarray, # sums_buf (float32, shape (led_count, 3))
np.ndarray, # starts_buf (float32, shape (led_count, 3))
np.ndarray, # out_uint8 (uint8, shape (led_count, 3))
]
def _build_cache(edge_len: int, led_count: int) -> _CacheEntry:
"""Pre-allocate all scratch buffers for one (edge_len, led_count) pair."""
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
# Ensure monotonically increasing boundaries even when ``step`` < 1.
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
np.minimum(boundaries, edge_len, out=boundaries)
starts = boundaries[:-1]
ends = boundaries[1:]
lengths = (ends - starts).reshape(-1, 1).astype(np.float32)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float32)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float32)
sums_buf = np.empty((led_count, 3), dtype=np.float32)
starts_buf = np.empty((led_count, 3), dtype=np.float32)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
return (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
def average_edge_to_leds(
edge_pixels: np.ndarray,
edge_name: str,
led_count: int,
cache: Dict[Hashable, _CacheEntry],
cache_key: Hashable,
) -> np.ndarray:
"""Vectorised average colour per LED segment.
``edge_pixels`` is shape ``(H, W, 3)``. For top/bottom edges we average
over axis=0 (collapsing rows), then segment along the width; for
left/right edges we average over axis=1 then segment along the height.
Returns a view into the caller-owned cache's ``out_uint8`` buffer —
do NOT retain the result across calls without copying.
"""
if edge_name in ("top", "bottom"):
axis = 0
edge_len = edge_pixels.shape[1]
else:
axis = 1
edge_len = edge_pixels.shape[0]
entry = cache.get(cache_key)
if entry is None or entry[0] != edge_len or entry[1] != led_count:
entry = _build_cache(edge_len, led_count)
cache[cache_key] = entry
(
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = entry
# Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
# Cumulative sum so each LED segment's sum is two array lookups apart.
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
# segment_sum[i] = cumsum[ends[i]] - cumsum[starts[i]]
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
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")
return out_uint8
def fallback_edge_to_leds(
edge_pixels: np.ndarray,
edge_name: str,
led_count: int,
calc_color: Callable[[np.ndarray], Any],
) -> np.ndarray:
"""Per-LED colour mapping for median / dominant modes.
Iterates LED segments and delegates colour reduction to ``calc_color``
(which is e.g. ``np.median`` for median mode, ``_dominant_colour`` for
dominant). Slower than ``average_edge_to_leds`` but supports any
reducer over the segment's pixels.
"""
if edge_name in ("top", "bottom"):
edge_len = edge_pixels.shape[1]
else:
edge_len = edge_pixels.shape[0]
step = edge_len / led_count
result = np.empty((led_count, 3), dtype=np.uint8)
for i in range(led_count):
start = int(i * step)
end = max(start + 1, int((i + 1) * step))
end = min(end, edge_len)
if edge_name in ("top", "bottom"):
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
result[i] = calc_color(segment)
return result
@@ -14,6 +14,12 @@ from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rat
logger = get_logger(__name__)
# Reused random Generator for sampling. The legacy ``np.random.randint``
# uses the module-level RandomState which is slightly slower per-call and
# pulls in extra import-time work; a single Generator is faster and avoids
# global-state surprises.
_rng = np.random.default_rng()
@dataclass
class DisplayInfo:
@@ -326,7 +332,11 @@ def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
max_samples = 1000
if n > max_samples:
indices = np.random.randint(0, n, max_samples)
# ``Generator.integers`` writes into a fresh buffer once per call;
# the legacy ``np.random.randint`` did the same plus extra
# bookkeeping. Random (not stride) sampling stays robust against
# periodic patterns in screen pixels.
indices = _rng.integers(0, n, size=max_samples)
pixels_reshaped = pixels_reshaped[indices]
# Quantize to 32 levels/channel (drop low 3 bits) and pack into uint32:
@@ -85,6 +85,10 @@ class DiscoveryWatcher:
self._wled_seen: Dict[str, _DiscoveredEntry] = {}
# device-path -> entry. Only the serial poller mutates this.
self._serial_seen: Dict[str, _DiscoveredEntry] = {}
# Strong references for fire-and-forget resolve tasks — without
# these, Python 3.11+ may GC the task mid-resolve and silently lose
# discovery events. Tasks remove themselves on completion.
self._resolve_tasks: "set[asyncio.Task]" = set()
# --- lifecycle --------------------------------------------------------
@@ -155,12 +159,23 @@ class DiscoveryWatcher:
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
# Resolve in a task — async_request blocks the handler if awaited
# synchronously and we don't want to stall mDNS dispatch.
asyncio.create_task(self._resolve_wled(service_type, name))
task = asyncio.create_task(self._resolve_wled(service_type, name))
self._resolve_tasks.add(task)
task.add_done_callback(self._on_resolve_done)
elif state_change == ServiceStateChange.Removed:
entry = self._wled_seen.pop(name, None)
if entry is not None and not self._is_configured(entry.url):
self._emit("device_lost", entry)
def _on_resolve_done(self, task: "asyncio.Task") -> None:
"""Release the strong task reference and log any resolve failure."""
self._resolve_tasks.discard(task)
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.debug("Discovery watcher: resolve task raised: %s", exc)
async def _resolve_wled(self, service_type: str, name: str) -> None:
if self._aiozc is None:
return
@@ -453,8 +453,15 @@ class WLEDClient(LEDClient):
],
}
logger.debug(f"Sending {len(pixels)} LEDs via HTTP ({len(indexed_pixels)} values)")
logger.debug(f"Payload size: ~{len(str(payload))} bytes")
# ``str(payload)`` previously stringified the entire indexed
# array on every send to report a byte estimate; that was the
# hot path. Drop the size readout — the LED count + indexed
# value count is enough to interpret traffic and is O(1).
logger.debug(
"Sending %d LEDs via HTTP (%d indexed values)",
len(pixels),
len(indexed_pixels),
)
await self._request("POST", "/json/state", json_data=payload)
logger.debug("Successfully sent pixel colors via HTTP")
@@ -98,11 +98,18 @@ class WLEDDeviceProvider(LEDDeviceProvider):
dict with 'led_count' key.
Raises:
ValueError: Unsupported scheme or invalid LED count.
httpx.ConnectError: Device unreachable.
httpx.TimeoutException: Connection timed out.
ValueError: Invalid LED count.
"""
url = _normalize_url(url)
# Reject anything that isn't plain HTTP(S). url_scheme.infer_http_scheme
# passes non-HTTP schemes through untouched ("javascript:", "file:",
# "data:", etc.); without this guard those would reach httpx and
# surface as opaque transport errors at best, or be silently misused
# at worst.
if not url.lower().startswith(("http://", "https://")):
raise ValueError(f"WLED URL must use http:// or https:// scheme (got {url!r})")
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(_join(url, "/json/info"))
response.raise_for_status()
+58 -6
View File
@@ -49,6 +49,32 @@ class MQTTRuntime:
# Pending publishes queued while disconnected
self._publish_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
# Strong references for fire-and-forget callback dispatch tasks.
# Python 3.11+ may GC bare ``asyncio.create_task(...)`` results mid-
# flight, so we hold each task until it completes and surface any
# exception via the done-callback.
self._dispatch_tasks: Set[asyncio.Task] = set()
# Compiled ``aiomqtt.Topic`` cache, keyed by the subscription pattern
# string. The previous dispatch loop re-parsed every pattern on
# every incoming message — on a chatty broker with many wildcards
# that adds up fast.
self._compiled_topics: Dict[str, aiomqtt.Topic] = {}
def _on_dispatch_done(self, task: asyncio.Task) -> None:
"""Drop the strong reference and surface any callback exception."""
self._dispatch_tasks.discard(task)
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.error(
"MQTT async callback raised (%s): %s",
self._source_id,
exc,
exc_info=exc,
)
@property
def is_connected(self) -> bool:
return self._connected
@@ -84,6 +110,14 @@ class MQTTRuntime:
logger.debug("MQTT runtime task cancelled: %s", self._source_id)
self._task = None
self._connected = False
# Cancel any in-flight async dispatch callbacks. Without this they
# would keep running past the runtime's logical end of life and
# could fire callbacks on a stopped subscriber.
for task in list(self._dispatch_tasks):
task.cancel()
if self._dispatch_tasks:
await asyncio.gather(*self._dispatch_tasks, return_exceptions=True)
self._dispatch_tasks.clear()
logger.info("MQTT runtime stopped: %s", self._source_id)
def update_config(self, source: MQTTSource) -> None:
@@ -167,13 +201,23 @@ class MQTTRuntime:
for topic in self._subscriptions:
await client.subscribe(topic)
# Drain pending publishes
# Drain pending publishes. A single publish failing
# (broker rejection, oversized message) must not lose
# the rest of the queue — log and continue with the next.
while not self._publish_queue.empty():
try:
t, p, r, q = self._publish_queue.get_nowait()
await client.publish(t, p, retain=r, qos=q)
except Exception:
except asyncio.QueueEmpty:
break
try:
await client.publish(t, p, retain=r, qos=q)
except Exception as exc:
logger.warning(
"MQTT drain publish failed (%s -> %s): %s",
self._source_id,
t,
exc,
)
# Message receive loop
async for msg in client.messages:
@@ -183,13 +227,21 @@ class MQTTRuntime:
)
self._topic_cache[topic_str] = payload_str
# Dispatch to callbacks
# Dispatch to callbacks. Pattern objects are cached
# per subscription string to avoid re-parsing them on
# every received message.
for sub_topic, callbacks in self._subscriptions.items():
if aiomqtt.Topic(sub_topic).matches(msg.topic):
compiled = self._compiled_topics.get(sub_topic)
if compiled is None:
compiled = aiomqtt.Topic(sub_topic)
self._compiled_topics[sub_topic] = compiled
if compiled.matches(msg.topic):
for cb in callbacks:
try:
if asyncio.iscoroutinefunction(cb):
asyncio.create_task(cb(topic_str, payload_str))
task = asyncio.create_task(cb(topic_str, payload_str))
self._dispatch_tasks.add(task)
task.add_done_callback(self._on_dispatch_done)
else:
cb(topic_str, payload_str)
except Exception as e:
@@ -9,12 +9,12 @@ from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise
from .gradient import GradientColorStripStream
from .helpers import _compute_gradient_colors
from .picture import PictureColorStripStream
from .static import StaticColorStripStream
from .single import SingleColorStripStream
__all__ = [
"ColorStripStream",
"PictureColorStripStream",
"StaticColorStripStream",
"SingleColorStripStream",
"GradientColorStripStream",
"_compute_gradient_colors",
"_SimpleNoise1D",
@@ -1,4 +1,4 @@
"""Static color strip stream — solid color with optional animation."""
"""Single color strip stream — solid color with optional animation."""
import colorsys
import math
@@ -18,7 +18,7 @@ from .base import ColorStripStream
logger = get_logger(__name__)
class StaticColorStripStream(ColorStripStream):
class SingleColorStripStream(ColorStripStream):
"""Color strip stream that returns a constant single-color array.
When animation is enabled a 30 fps background thread updates _colors with
@@ -28,7 +28,7 @@ class StaticColorStripStream(ColorStripStream):
def __init__(self, source):
"""
Args:
source: StaticColorStripSource config
source: SingleColorStripSource config
"""
self._colors_lock = threading.Lock()
self._running = False
@@ -64,7 +64,7 @@ class StaticColorStripStream(ColorStripStream):
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
self._led_count = device_led_count
self._rebuild_colors()
logger.debug(f"StaticColorStripStream auto-sized to {device_led_count} LEDs")
logger.debug(f"SingleColorStripStream auto-sized to {device_led_count} LEDs")
@property
def target_fps(self) -> int:
@@ -98,36 +98,36 @@ class StaticColorStripStream(ColorStripStream):
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name="css-static-animate",
name="css-single-animate",
daemon=True,
)
self._thread.start()
logger.info(f"StaticColorStripStream started (leds={self._led_count})")
logger.info(f"SingleColorStripStream started (leds={self._led_count})")
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning("StaticColorStripStream animate thread did not terminate within 5s")
logger.warning("SingleColorStripStream animate thread did not terminate within 5s")
self._thread = None
logger.info("StaticColorStripStream stopped")
logger.info("SingleColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from ledgrab.storage.color_strip_source import StaticColorStripSource
from ledgrab.storage.color_strip_source import SingleColorStripSource
if isinstance(source, StaticColorStripSource):
if isinstance(source, SingleColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
# If we were auto-sized, preserve the runtime LED count across updates
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
self._rebuild_colors()
logger.info("StaticColorStripStream params updated in-place")
logger.info("SingleColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
@@ -266,7 +266,7 @@ class StaticColorStripStream(ColorStripStream):
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"StaticColorStripStream animation error: {e}")
logger.error(f"SingleColorStripStream animation error: {e}")
if (anim and anim.get("enabled")) or self._is_color_bound():
sleep_target = frame_time
@@ -274,6 +274,6 @@ class StaticColorStripStream(ColorStripStream):
sleep_target = 0.25
limiter.wait(sleep_target)
except Exception as e:
logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
logger.error(f"Fatal SingleColorStripStream loop error: {e}", exc_info=True)
finally:
self._running = False
@@ -0,0 +1,273 @@
"""Single source of truth for non-sharable color-strip stream construction.
Both the preview WebSocket (``api/routes/color_strip_sources/ws_stream.py``)
and the production ``ColorStripStreamManager.acquire`` used to maintain
parallel ``if source.source_type == "..." elif ... else ..._SIMPLE_STREAM_MAP``
chains. Adding a new kind required keeping those two chains in lockstep, and
silently fell through to a generic stream class when an entry was missed.
This module replaces both chains with a single ``STREAM_BUILDERS`` registry
plus a small ``StreamDeps`` dependency bag. Each caller populates the bag
from its own context (DI container, processor manager, etc.) and looks the
builder up by ``source.source_type``. A coverage assertion at import time
guarantees every kind in ``storage._SOURCE_TYPE_MAP`` is either sharable or
has a builder here — silent fall-throughs are no longer possible.
Sharable kinds (``picture``, ``picture_advanced``, ``key_colors``) are NOT in
this registry: they require an injected ``LiveStream`` whose acquisition is
intertwined with the source's calibration, which does not fit a uniform
factory signature. Those continue to use bespoke paths inside
``ColorStripStreamManager``.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
@dataclass(frozen=True)
class StreamDeps:
"""Dependency bag for non-sharable stream construction.
Each call site (preview WebSocket, production stream manager) builds one
of these from its own context before invoking :func:`build_stream`.
Fields are ``Any`` (with ``None`` defaults) because individual builders
only consume a subset; tests can supply a minimal bag.
``frozen=True`` guards against a builder accidentally reassigning a
field; it does NOT make the referenced objects immutable — the
``css_manager``, stores, etc. are live mutable services.
``css_manager`` is needed by composite/mapped/processed builders so they
can recursively acquire dependent streams. Single-kind builders ignore
it. The field has no default so callers are forced to think about which
manager they are wiring through.
"""
css_manager: Any
value_stream_manager: Any = None
cspt_store: Any = None # ColorStripProcessingTemplateStore
weather_manager: Any = None
audio_capture_manager: Any = None
audio_source_store: Any = None
audio_template_store: Any = None
audio_processing_template_store: Any = None
game_event_bus: Any = None
depth: int = 0 # composite nesting depth — passed through verbatim
# ---------------------------------------------------------------------------
# Per-kind builders
#
# Each builder is a small free function ``(source, deps) -> ColorStripStream``.
# Imports are deferred to keep this module cheap to import (the production
# processing graph is large).
# ---------------------------------------------------------------------------
def _build_audio(source, d: StreamDeps):
from ledgrab.core.processing.audio_stream import AudioColorStripStream
return AudioColorStripStream(
source,
d.audio_capture_manager,
d.audio_source_store,
d.audio_template_store,
d.audio_processing_template_store,
)
def _build_composite(source, d: StreamDeps):
from ledgrab.core.processing.composite_stream import CompositeColorStripStream
return CompositeColorStripStream(
source,
d.css_manager,
d.value_stream_manager,
d.cspt_store,
depth=d.depth,
)
def _build_mapped(source, d: StreamDeps):
from ledgrab.core.processing.mapped_stream import MappedColorStripStream
return MappedColorStripStream(source, d.css_manager)
def _build_processed(source, d: StreamDeps):
from ledgrab.core.processing.processed_stream import ProcessedColorStripStream
return ProcessedColorStripStream(source, d.css_manager, d.cspt_store)
def _build_weather(source, d: StreamDeps):
from ledgrab.core.processing.weather_stream import WeatherColorStripStream
return WeatherColorStripStream(source, d.weather_manager)
def _build_game_event(source, d: StreamDeps):
from ledgrab.core.processing.game_event_stream import GameEventColorStripStream
stream = GameEventColorStripStream(source)
if d.game_event_bus is not None:
stream.set_event_bus(d.game_event_bus)
return stream
def _make_source_only_builder(loader: Callable[[], type]) -> Callable[[Any, StreamDeps], Any]:
"""Wrap a class-loader so it produces a uniform ``(source, deps) -> stream`` builder.
The loader is called on each invocation but module caching makes the
import a single dict lookup after the first call.
"""
def _build(source, _deps: StreamDeps):
return loader()(source)
return _build
def _single_color_cls() -> type:
from ledgrab.core.processing.color_strip_stream import SingleColorStripStream
return SingleColorStripStream
def _gradient_cls() -> type:
from ledgrab.core.processing.color_strip_stream import GradientColorStripStream
return GradientColorStripStream
def _effect_cls() -> type:
from ledgrab.core.processing.effect_stream import EffectColorStripStream
return EffectColorStripStream
def _api_input_cls() -> type:
from ledgrab.core.processing.api_input_stream import ApiInputColorStripStream
return ApiInputColorStripStream
def _notification_cls() -> type:
from ledgrab.core.processing.notification_stream import NotificationColorStripStream
return NotificationColorStripStream
def _daylight_cls() -> type:
from ledgrab.core.processing.daylight_stream import DaylightColorStripStream
return DaylightColorStripStream
def _candlelight_cls() -> type:
from ledgrab.core.processing.candlelight_stream import CandlelightColorStripStream
return CandlelightColorStripStream
def _math_wave_cls() -> type:
from ledgrab.core.processing.math_wave_stream import MathWaveColorStripStream
return MathWaveColorStripStream
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
StreamBuilder = Callable[[Any, StreamDeps], Any]
STREAM_BUILDERS: dict[str, StreamBuilder] = {
"audio": _build_audio,
"composite": _build_composite,
"mapped": _build_mapped,
"processed": _build_processed,
"weather": _build_weather,
"game_event": _build_game_event,
"single_color": _make_source_only_builder(_single_color_cls),
# Legacy alias: pre-rename rows used "static". The data migration rewrites
# the on-disk source_type on startup, but this alias keeps an in-flight
# legacy entry resolving to the right stream class.
"static": _make_source_only_builder(_single_color_cls),
"gradient": _make_source_only_builder(_gradient_cls),
"effect": _make_source_only_builder(_effect_cls),
"api_input": _make_source_only_builder(_api_input_cls),
"notification": _make_source_only_builder(_notification_cls),
"daylight": _make_source_only_builder(_daylight_cls),
"candlelight": _make_source_only_builder(_candlelight_cls),
"math_wave": _make_source_only_builder(_math_wave_cls),
}
# Sharable kinds are handled by dedicated LiveStream-acquisition paths in
# ColorStripStreamManager (their construction depends on calibration → picture
# source resolution, which does not fit a uniform factory signature).
SHARABLE_KINDS: frozenset[str] = frozenset({"picture", "picture_advanced", "key_colors"})
def build_stream(source, deps: StreamDeps):
"""Build a non-sharable color-strip stream for *source*.
Raises ``ValueError`` if the kind has no registered builder (which
would indicate a sharable source slipped through the caller's
``sharable`` gate, or a new kind missing from this registry).
"""
builder = STREAM_BUILDERS.get(source.source_type)
if builder is None:
raise ValueError(
f"No stream builder for non-sharable color-strip-source kind "
f"{source.source_type!r} (id={getattr(source, 'id', '?')!r})"
)
return builder(source, deps)
def _assert_stream_kind_coverage() -> None:
"""Verify the registry is a strict partition: every kind from storage is
either listed in SHARABLE_KINDS or has a STREAM_BUILDERS entry.
Runs at module import so a kind added to ``_SOURCE_TYPE_MAP`` without a
corresponding builder fails the server boot loudly instead of silently
falling through at request time.
Contract note
-------------
This check is **asymmetric** (``STREAM_BUILDERS SHARABLE_KINDS ==
storage_kinds``) because sharable kinds are constructed by a separate
path inside ``ColorStripStreamManager``. The sister assertion in
``api/routes/color_strip_sources/_helpers.py::_assert_response_map_coverage``
is **symmetric** (``_RESPONSE_MAP keys == storage_kinds``) because every
kind, sharable or not, still needs a response shape. Both assertions key
by the ``source_type`` string; adding a new kind requires updates to
storage, ``_RESPONSE_MAP``, and either ``STREAM_BUILDERS`` or
``SHARABLE_KINDS``. Both assertions catch missing entries; only this one
expects a subset relationship.
"""
from ledgrab.storage.color_strip_source import _SOURCE_TYPE_MAP
storage_kinds = set(_SOURCE_TYPE_MAP.keys())
builder_kinds = set(STREAM_BUILDERS.keys())
expected_non_sharable = storage_kinds - SHARABLE_KINDS
missing = expected_non_sharable - builder_kinds
extra = builder_kinds - storage_kinds
if missing or extra:
problems = []
if missing:
problems.append(f"missing builders: {sorted(missing)}")
if extra:
problems.append(f"unregistered kinds: {sorted(extra)}")
raise RuntimeError(
"color_strip_kinds.STREAM_BUILDERS is out of sync with storage._SOURCE_TYPE_MAP: "
+ "; ".join(problems)
)
_assert_stream_kind_coverage()
@@ -9,7 +9,7 @@ from ledgrab.core.processing.color_strip import ( # noqa: F401
ColorStripStream,
GradientColorStripStream,
PictureColorStripStream,
StaticColorStripStream,
SingleColorStripStream,
_compute_gradient_colors,
_gradient_noise,
_SimpleNoise1D,
@@ -18,7 +18,7 @@ from ledgrab.core.processing.color_strip import ( # noqa: F401
__all__ = [
"ColorStripStream",
"PictureColorStripStream",
"StaticColorStripStream",
"SingleColorStripStream",
"GradientColorStripStream",
"_compute_gradient_colors",
"_SimpleNoise1D",
@@ -3,7 +3,7 @@
PictureColorStripStreams (expensive screen capture) are shared across multiple
consumers via reference counting — processing runs once, not once per target.
Count-dependent streams (static, gradient, effect) are NOT shared.
Count-dependent streams (single_color, gradient, effect) are NOT shared.
Each consumer gets its own instance so it can configure an independent LED count
without interfering with other targets.
"""
@@ -11,37 +11,18 @@ without interfering with other targets.
from dataclasses import dataclass
from typing import Dict, Optional
from ledgrab.core.processing.color_strip_kinds import (
StreamDeps,
build_stream,
)
from ledgrab.core.processing.color_strip_stream import (
ColorStripStream,
GradientColorStripStream,
PictureColorStripStream,
StaticColorStripStream,
)
from ledgrab.core.processing.processed_stream import ProcessedColorStripStream
from ledgrab.core.processing.effect_stream import EffectColorStripStream
from ledgrab.core.processing.api_input_stream import ApiInputColorStripStream
from ledgrab.core.processing.notification_stream import NotificationColorStripStream
from ledgrab.core.processing.daylight_stream import DaylightColorStripStream
from ledgrab.core.processing.candlelight_stream import CandlelightColorStripStream
from ledgrab.core.processing.game_event_stream import GameEventColorStripStream
from ledgrab.core.processing.math_wave_stream import MathWaveColorStripStream
from ledgrab.utils import get_logger
logger = get_logger(__name__)
# source_type → stream class for non-picture (non-sharable) sources
_SIMPLE_STREAM_MAP = {
"static": StaticColorStripStream,
"gradient": GradientColorStripStream,
"effect": EffectColorStripStream,
"api_input": ApiInputColorStripStream,
"notification": NotificationColorStripStream,
"daylight": DaylightColorStripStream,
"candlelight": CandlelightColorStripStream,
"game_event": GameEventColorStripStream,
"math_wave": MathWaveColorStripStream,
}
@dataclass
class _ColorStripEntry:
@@ -241,43 +222,27 @@ class ColorStripStreamManager:
"""
source = self._color_strip_store.get_source(css_id)
# Non-sharable: always create a fresh per-consumer instance
# Non-sharable: always create a fresh per-consumer instance.
# Construction is delegated to the per-kind registry in
# ``color_strip_kinds`` so the dispatch lives in exactly one place.
if not source.sharable:
if source.source_type == "audio":
from ledgrab.core.processing.audio_stream import AudioColorStripStream
css_stream = AudioColorStripStream(
source,
self._audio_capture_manager,
self._audio_source_store,
self._audio_template_store,
self._audio_processing_template_store,
)
elif source.source_type == "composite":
from ledgrab.core.processing.composite_stream import (
CompositeColorStripStream,
)
css_stream = CompositeColorStripStream(
source, self, self._value_stream_manager, self._cspt_store, depth=depth
)
elif source.source_type == "mapped":
from ledgrab.core.processing.mapped_stream import MappedColorStripStream
css_stream = MappedColorStripStream(source, self)
elif source.source_type == "processed":
css_stream = ProcessedColorStripStream(source, self, self._cspt_store)
elif source.source_type == "weather":
from ledgrab.core.processing.weather_stream import WeatherColorStripStream
css_stream = WeatherColorStripStream(source, self._weather_manager)
else:
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls:
raise ValueError(
f"Unsupported color strip source type '{source.source_type}' for {css_id}"
)
css_stream = stream_cls(source)
deps = StreamDeps(
css_manager=self,
value_stream_manager=self._value_stream_manager,
cspt_store=self._cspt_store,
weather_manager=self._weather_manager,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=self._audio_source_store,
audio_template_store=self._audio_template_store,
audio_processing_template_store=self._audio_processing_template_store,
game_event_bus=self._game_event_bus,
depth=depth,
)
try:
css_stream = build_stream(source, deps)
except ValueError as e:
# Surface the css_id alongside the registry's error.
raise ValueError(f"{e} (css_id={css_id})") from e
# 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)
@@ -11,7 +11,7 @@ no external dependencies are required.
import math
import threading
import time
from typing import Dict, Optional
from typing import Callable, Dict, Optional
import numpy as np
@@ -22,6 +22,54 @@ from ledgrab.utils.timer import high_resolution_timer
logger = get_logger(__name__)
# -- Effect renderer registry --
# A small attribute-marker + class-decorator pair: the per-method
# ``@_effect_renderer("fire")`` decorator stamps a name onto the unbound
# method; ``_collect_effect_renderers`` (applied to the class) scans those
# stamps and builds a ``cls._RENDERERS`` dict. This replaces the inline
# ``renderers = {"fire": self._render_fire, ...}`` dict that the animation
# loop used to rebuild every frame, and the silent ``.get(..., self._render_fire)``
# fallback that turned a typo in ``_effect_type`` into a hidden fire-renderer.
def _effect_renderer(name: str) -> Callable:
"""Mark a method as the renderer for the given effect type.
The actual collection happens via ``@_collect_effect_renderers`` on the
enclosing class — decorating the method alone is harmless if the class
decorator is omitted.
"""
def _decorator(method):
method._effect_renderer_name = name
return method
return _decorator
def _collect_effect_renderers(cls):
"""Class decorator: gather methods tagged with ``@_effect_renderer``
into ``cls._RENDERERS``.
Runs once at class-creation. Raises ``RuntimeError`` if two methods
register under the same name (catches copy-paste typos).
"""
renderers: Dict[str, Callable] = {}
for attr_name in dir(cls):
member = getattr(cls, attr_name, None)
effect_name = getattr(member, "_effect_renderer_name", None)
if effect_name is None:
continue
if effect_name in renderers:
raise RuntimeError(
f"Duplicate @_effect_renderer({effect_name!r}) registration on {cls.__name__}"
)
renderers[effect_name] = member
cls._RENDERERS = renderers
return cls
# ── Palette LUT system ──────────────────────────────────────────────────
# Each palette is a list of (position, R, G, B) control points.
@@ -211,13 +259,14 @@ _EFFECT_DEFAULT_PALETTE = {
}
@_collect_effect_renderers
class EffectColorStripStream(ColorStripStream):
"""Color strip stream that runs a procedural LED effect.
Dispatches to one of five render methods based on effect_type:
fire, meteor, plasma, noise, aurora.
Uses the same lifecycle pattern as StaticColorStripStream:
Uses the same lifecycle pattern as SingleColorStripStream:
background thread, double-buffered output, configure() for auto-sizing.
"""
@@ -367,21 +416,10 @@ class EffectColorStripStream(ColorStripStream):
_buf_a = _buf_b = None
_use_a = True
# Dispatch table
renderers = {
"fire": self._render_fire,
"meteor": self._render_meteor,
"plasma": self._render_plasma,
"noise": self._render_noise,
"aurora": self._render_aurora,
"rain": self._render_rain,
"comet": self._render_comet,
"bouncing_ball": self._render_bouncing_ball,
"fireworks": self._render_fireworks,
"sparkle_rain": self._render_sparkle_rain,
"lava_lamp": self._render_lava_lamp,
"wave_interference": self._render_wave_interference,
}
# Dispatch via the class-level registry built by
# ``@_collect_effect_renderers`` at class-creation time. Renderers
# are unbound methods, so each call passes ``self`` explicitly.
renderers = type(self)._RENDERERS
limiter = FrameLimiter(self._fps)
@@ -424,8 +462,19 @@ class EffectColorStripStream(ColorStripStream):
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
render_fn = renderers.get(self._effect_type, self._render_fire)
render_fn(buf, n, anim_time)
render_fn = renderers.get(self._effect_type)
if render_fn is None:
# Unknown effect type — log once per loop pass and
# skip rendering rather than silently falling back
# to fire (the previous behaviour, which hid typos
# in ``_effect_type``).
logger.warning(
"EffectColorStripStream: unknown effect_type %r — skipping frame",
self._effect_type,
)
time.sleep(frame_time)
continue
render_fn(self, buf, n, anim_time)
with self._colors_lock:
self._colors = buf
@@ -440,6 +489,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Fire ─────────────────────────────────────────────────────────
@_effect_renderer("fire")
def _render_fire(self, buf: np.ndarray, n: int, t: float) -> None:
"""Heat-propagation fire simulation.
@@ -490,6 +540,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Meteor ───────────────────────────────────────────────────────
@_effect_renderer("meteor")
def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None:
"""Bright meteor head with exponential-decay trail."""
speed = self._effective_speed
@@ -560,6 +611,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Plasma ───────────────────────────────────────────────────────
@_effect_renderer("plasma")
def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None:
"""Overlapping sine waves creating colorful plasma patterns."""
speed = self._effective_speed
@@ -587,6 +639,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Perlin Noise ─────────────────────────────────────────────────
@_effect_renderer("noise")
def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None:
"""Smooth scrolling fractal noise mapped to a color palette."""
speed = self._effective_speed
@@ -605,6 +658,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Aurora ───────────────────────────────────────────────────────
@_effect_renderer("aurora")
def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None:
"""Layered noise bands simulating aurora borealis."""
speed = self._effective_speed
@@ -651,6 +705,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Rain ──────────────────────────────────────────────────────────
@_effect_renderer("rain")
def _render_rain(self, buf: np.ndarray, n: int, t: float) -> None:
"""Raindrops falling down the strip with trailing tails."""
speed = self._effective_speed
@@ -686,6 +741,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Comet ─────────────────────────────────────────────────────────
@_effect_renderer("comet")
def _render_comet(self, buf: np.ndarray, n: int, t: float) -> None:
"""Multiple comets with curved, pulsing tails."""
speed = self._effective_speed
@@ -732,6 +788,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Bouncing Ball ─────────────────────────────────────────────────
@_effect_renderer("bouncing_ball")
def _render_bouncing_ball(self, buf: np.ndarray, n: int, t: float) -> None:
"""Physics-simulated bouncing balls with gravity."""
speed = self._effective_speed
@@ -795,6 +852,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Fireworks ─────────────────────────────────────────────────────
@_effect_renderer("fireworks")
def _render_fireworks(self, buf: np.ndarray, n: int, t: float) -> None:
"""Rockets launch and explode into colorful particle bursts."""
speed = self._effective_speed
@@ -868,6 +926,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Sparkle Rain ──────────────────────────────────────────────────
@_effect_renderer("sparkle_rain")
def _render_sparkle_rain(self, buf: np.ndarray, n: int, t: float) -> None:
"""Twinkling star field with smooth fade-in/fade-out."""
speed = self._effective_speed
@@ -904,6 +963,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Lava Lamp ─────────────────────────────────────────────────────
@_effect_renderer("lava_lamp")
def _render_lava_lamp(self, buf: np.ndarray, n: int, t: float) -> None:
"""Slow-moving colored blobs that merge and separate."""
speed = self._effective_speed
@@ -945,6 +1005,7 @@ class EffectColorStripStream(ColorStripStream):
# ── Wave Interference ─────────────────────────────────────────────
@_effect_renderer("wave_interference")
def _render_wave_interference(self, buf: np.ndarray, n: int, t: float) -> None:
"""Two counter-propagating sine waves creating interference patterns."""
speed = self._effective_speed
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from ledgrab.core.processing.light_target_helpers import swap_color_source
from ledgrab.core.processing.target_processor import TargetContext, TargetProcessor
from ledgrab.storage.ha_light_output_target import HALightMapping
from ledgrab.utils import get_logger
@@ -255,50 +256,11 @@ class HALightTargetProcessor(TargetProcessor):
def _swap_color_source(self, new_kind: str, new_color_vs_id: str) -> None:
"""Release the previous colour stream and acquire the new one."""
# Tear down previous stream first to keep ref-counts honest.
if self._is_running:
if self._css_stream and self._ctx.color_strip_stream_manager:
try:
self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
except Exception:
pass
self._css_stream = None
if self._color_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._color_vs_id)
except Exception:
pass
self._color_stream = None
self._source_kind = new_kind
self._color_vs_id = new_color_vs_id
swap_color_source(self, new_kind, new_color_vs_id, log_label="HA light")
# Reset per-entity history so the new source isn't gated by stale values.
self._previous_colors.clear()
self._previous_on.clear()
if not self._is_running:
return
if self._source_kind == "color_vs":
if self._color_vs_id and self._ctx.value_stream_manager:
try:
self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
except Exception as e:
logger.warning(
f"HA light {self._target_id}: failed to acquire color VS stream: {e}"
)
else:
if self._css_id and self._ctx.color_strip_stream_manager:
try:
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
self._css_id, self._target_id
)
except Exception as e:
logger.warning(
f"HA light {self._target_id}: failed to re-acquire CSS stream: {e}"
)
# ── WebSocket clients ──
def add_ws_client(self, ws: Any) -> None:
@@ -0,0 +1,114 @@
"""Shared helpers for HA / Zigbee2MQTT light target processors.
``HALightTargetProcessor`` and ``Z2MLightTargetProcessor`` historically
duplicated their colour-source swap logic character-for-character only
the log prefix and a docstring differed (audit finding C5). This module
hosts the deduplicated implementation.
We deliberately stop short of extracting a full ``BaseLightTargetProcessor``
ABC here: the read sites for the per-processor state are spread across ~38
locations per file and a wholesale composition refactor risks regressing
the live LED control loop. The free-function approach below is the
minimum-blast-radius way to delete the duplication. The processor still
owns its state; the helper reaches in to mutate it, which is ugly but
Pythonic and isolated to two methods.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ledgrab.utils import get_logger
if TYPE_CHECKING:
# ``LightTargetSwapState`` is a structural protocol — anything carrying
# the listed attributes is acceptable. We do not import the concrete
# processor classes to avoid a circular dependency.
from typing import Any, Protocol
class LightTargetSwapState(Protocol):
_is_running: bool
_css_stream: Any
_color_stream: Any
_ctx: Any
_css_id: str
_color_vs_id: str
_source_kind: str
_target_id: str
logger = get_logger(__name__)
def swap_color_source(
processor: "LightTargetSwapState",
new_kind: str,
new_color_vs_id: str,
*,
log_label: str,
) -> None:
"""Release the current colour source and acquire the new one.
Mirrors what ``HALightTargetProcessor._swap_color_source`` and
``Z2MLightTargetProcessor._swap_color_source`` used to do inline.
The caller is responsible for clearing per-entity history
(``_previous_colors``, ``_previous_on``) after this returns
that state is owned by the processor, not the colour source.
``log_label`` is the short identifier used in warning logs
(e.g. ``"HA light"`` or ``"Z2M light"``) so a failure is
traceable back to the right processor in mixed deployments.
"""
# Tear down the previously-acquired stream first to keep ref-counts honest.
if processor._is_running:
if processor._css_stream and processor._ctx.color_strip_stream_manager:
try:
processor._ctx.color_strip_stream_manager.release(
processor._css_id, processor._target_id
)
except Exception:
# Manager-level errors are non-fatal: stream may already be
# gone if the source was deleted out from under us.
pass
processor._css_stream = None
if processor._color_stream is not None and processor._ctx.value_stream_manager:
try:
processor._ctx.value_stream_manager.release(processor._color_vs_id)
except Exception:
pass
processor._color_stream = None
processor._source_kind = new_kind
processor._color_vs_id = new_color_vs_id
if not processor._is_running:
# Not started yet; the start() path will acquire when called.
return
if processor._source_kind == "color_vs":
if processor._color_vs_id and processor._ctx.value_stream_manager:
try:
processor._color_stream = processor._ctx.value_stream_manager.acquire(
processor._color_vs_id
)
except Exception as e:
logger.warning(
"%s %s: failed to acquire color VS stream: %s",
log_label,
processor._target_id,
e,
)
else:
if processor._css_id and processor._ctx.color_strip_stream_manager:
try:
processor._css_stream = processor._ctx.color_strip_stream_manager.acquire(
processor._css_id, processor._target_id
)
except Exception as e:
logger.warning(
"%s %s: failed to re-acquire CSS stream: %s",
log_label,
processor._target_id,
e,
)
@@ -2,6 +2,7 @@
import threading
import time
from collections import OrderedDict
from typing import Dict, List, Optional
import numpy as np
@@ -11,6 +12,11 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__)
# Cap the (src_len, dst_len) resize cache. Each entry holds two ``np.linspace``
# arrays plus a per-zone ``uint8`` scratch buffer, which used to grow without
# bound under hot reconfigure storms.
_RESIZE_CACHE_MAX = 16
class MappedColorStripStream(ColorStripStream):
"""Places multiple ColorStripStreams side-by-side at distinct LED ranges.
@@ -46,8 +52,11 @@ class MappedColorStripStream(ColorStripStream):
# zone_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
self._resize_cache: Dict[tuple, tuple] = {}
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing.
# An ``OrderedDict`` with eviction keeps memory bounded if device
# configurations fluctuate at runtime (each unique pair adds two
# linspace arrays + a per-zone reusable uint8 buffer).
self._resize_cache: "OrderedDict[tuple, tuple]" = OrderedDict()
self._sub_lock = threading.Lock() # guards _sub_streams access across threads
# ── ColorStripStream interface ──────────────────────────────
@@ -229,6 +238,14 @@ class MappedColorStripStream(ColorStripStream):
np.empty((zone_len, 3), dtype=np.uint8),
)
self._resize_cache[rkey] = cached
# Drop the least-recently-inserted entry once
# we hit the cap. 16 entries comfortably covers
# any realistic zone/source layout — pathological
# reconfigure storms used to grow this forever.
if len(self._resize_cache) > _RESIZE_CACHE_MAX:
self._resize_cache.popitem(last=False)
else:
self._resize_cache.move_to_end(rkey)
src_x, dst_x, resized = cached
for ch in range(3):
np.copyto(
@@ -0,0 +1,271 @@
"""Per-metric reader / normaliser registry for ``SystemMetricsValueStream``.
The stream previously dispatched on a ``self._metric`` string across three
separate ``if / elif`` chains (priming in ``start()``, raw read in
``_read_metric_psutil`` + ``_read_metric_fallback``, normalisation in
``_normalize``). Adding a new metric meant editing every chain.
This module replaces all of that with a single ``METRIC_SPECS`` dict keyed
by metric name. Each :class:`MetricSpec` declares:
* ``read_psutil(stream)`` the desktop path that uses ``stream._psutil``;
* ``read_fallback(stream)`` the Android / no-psutil path (returns 0.0
for desktop-only sensors);
* ``normalize(stream, raw)`` maps the raw reading to ``[0, 1]``;
* ``prime(stream)`` optional one-time setup called from ``start()``.
The spec functions operate on the stream's existing attributes
(``_disk_path``, ``_sensor_label``, ``_min_val``, ``_max_val``,
``_max_rate``, ``_gpu_unavailable``, ``_prev_net_bytes``,
``_prev_net_time``). That is intentional: the readers are stateless
strategy callables, but the *stream's* state remains its own. Mutating it
from a reader is documented per function so the contract is explicit.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Dict, Optional
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.processing.value_stream import SystemMetricsValueStream
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Spec dataclass
# ---------------------------------------------------------------------------
ReaderFn = Callable[["SystemMetricsValueStream"], float]
NormalizeFn = Callable[["SystemMetricsValueStream", float], float]
PrimeFn = Callable[["SystemMetricsValueStream"], None]
@dataclass(frozen=True)
class MetricSpec:
"""How to read, normalise, and prime a single system metric."""
name: str
read_psutil: ReaderFn
read_fallback: ReaderFn
normalize: NormalizeFn
prime: Optional[PrimeFn] = None
# ---------------------------------------------------------------------------
# Normaliser primitives — shared across metrics
# ---------------------------------------------------------------------------
def _norm_percent(_s, raw: float) -> float:
"""Percent metrics (cpu_load, ram_usage, …) — raw is 0..100."""
return max(0.0, min(1.0, raw / 100.0))
def _norm_range(s, raw: float) -> float:
"""Temperature / fan-speed metrics normalise against (min_val, max_val)."""
rng = s._max_val - s._min_val
if abs(rng) < 1e-9:
return 0.5
return max(0.0, min(1.0, (raw - s._min_val) / rng))
def _norm_rate(s, raw: float) -> float:
"""Network rate normalises against ``_max_rate`` (bytes/s)."""
if s._max_rate <= 0:
return 0.5
return max(0.0, min(1.0, raw / s._max_rate))
def _zero(_s) -> float:
"""Desktop-only sensor on a no-psutil platform: report 0.0."""
return 0.0
# ---------------------------------------------------------------------------
# Read helpers — psutil paths
# ---------------------------------------------------------------------------
def _read_cpu_load_psutil(s) -> float:
return s._psutil.cpu_percent(interval=None)
def _read_ram_usage_psutil(s) -> float:
return s._psutil.virtual_memory().percent
def _read_disk_usage_psutil(s) -> float:
return s._psutil.disk_usage(s._disk_path).percent
def _read_battery_psutil(s) -> float:
bat = s._psutil.sensors_battery()
return bat.percent if bat else 0.0
def _read_cpu_temp_psutil(s) -> float:
psutil = s._psutil
if psutil is None:
return 0.0
temps = psutil.sensors_temperatures()
if not temps:
return 0.0
if s._sensor_label:
for group_name, entries in temps.items():
for entry in entries:
if entry.label == s._sensor_label or group_name == s._sensor_label:
return entry.current
for entries in temps.values():
if entries:
return entries[0].current
return 0.0
def _read_fan_speed_psutil(s) -> float:
psutil = s._psutil
if psutil is None:
return 0.0
fans = psutil.sensors_fans()
if not fans:
return 0.0
if s._sensor_label:
for group_name, entries in fans.items():
for entry in entries:
if entry.label == s._sensor_label or group_name == s._sensor_label:
return entry.current
for entries in fans.values():
if entries:
return entries[0].current
return 0.0
def _read_gpu(metric: str) -> ReaderFn:
"""Build a GPU reader for the given metric name ('gpu_load' or 'gpu_temp')."""
def _read(s) -> float:
if s._gpu_unavailable:
return 0.0
try:
from ledgrab.utils.gpu import nvml, nvml_available, nvml_handle
if not nvml_available or nvml_handle is None:
s._gpu_unavailable = True
return 0.0
if metric == "gpu_load":
util = nvml.nvmlDeviceGetUtilizationRates(nvml_handle)
return float(util.gpu)
# gpu_temp
return float(nvml.nvmlDeviceGetTemperature(nvml_handle, 0))
except Exception as e:
logger.debug("GPU metric read error: %s", e)
s._gpu_unavailable = True
return 0.0
return _read
def _read_network_rate(s) -> float:
"""Bytes/s rate for ``network_rx`` / ``network_tx``.
Mutates ``s._prev_net_bytes`` and ``s._prev_net_time`` to track the
delta between calls the stream owns the cadence state, this reader
just bumps it forward.
"""
psutil = s._psutil
if psutil is None:
return 0.0
counters = psutil.net_io_counters()
if not counters:
return 0.0
current_bytes = counters.bytes_recv if s._metric == "network_rx" else counters.bytes_sent
now = time.monotonic()
if s._prev_net_bytes is None or s._prev_net_time is None:
s._prev_net_bytes = current_bytes
s._prev_net_time = now
return 0.0
dt = now - s._prev_net_time
if dt <= 0:
return 0.0
# Cap delta time to avoid spikes after long gaps
dt = min(dt, s._poll_interval * 2)
rate = (current_bytes - s._prev_net_bytes) / dt
s._prev_net_bytes = current_bytes
s._prev_net_time = now
return max(0.0, rate)
# ---------------------------------------------------------------------------
# Read helpers — fallback (no-psutil) paths
# ---------------------------------------------------------------------------
def _read_cpu_load_fallback(_s) -> float:
from ledgrab.utils.metrics import get_metrics_provider
return get_metrics_provider().cpu_percent()
def _read_ram_usage_fallback(_s) -> float:
from ledgrab.utils.metrics import get_metrics_provider
mem = get_metrics_provider().virtual_memory()
if mem.total_bytes > 0:
return (mem.used_bytes / mem.total_bytes) * 100.0
return 0.0
# ---------------------------------------------------------------------------
# Prime helpers — one-time setup from start()
# ---------------------------------------------------------------------------
def _prime_cpu_load(s) -> None:
# Prime psutil.cpu_percent so the first real call returns meaningful data
if s._psutil is not None:
s._psutil.cpu_percent(interval=None)
def _prime_network(s) -> None:
"""Capture initial network counter so the first delta has a baseline."""
if s._psutil is None:
return
counters = s._psutil.net_io_counters()
if counters:
s._prev_net_bytes = (
counters.bytes_recv if s._metric == "network_rx" else counters.bytes_sent
)
s._prev_net_time = time.monotonic()
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
METRIC_SPECS: Dict[str, MetricSpec] = {
"cpu_load": MetricSpec(
"cpu_load", _read_cpu_load_psutil, _read_cpu_load_fallback, _norm_percent, _prime_cpu_load
),
"ram_usage": MetricSpec(
"ram_usage", _read_ram_usage_psutil, _read_ram_usage_fallback, _norm_percent
),
"disk_usage": MetricSpec("disk_usage", _read_disk_usage_psutil, _zero, _norm_percent),
"battery_level": MetricSpec("battery_level", _read_battery_psutil, _zero, _norm_percent),
"cpu_temp": MetricSpec("cpu_temp", _read_cpu_temp_psutil, _zero, _norm_range),
"fan_speed": MetricSpec("fan_speed", _read_fan_speed_psutil, _zero, _norm_range),
"gpu_load": MetricSpec("gpu_load", _read_gpu("gpu_load"), _zero, _norm_percent),
"gpu_temp": MetricSpec("gpu_temp", _read_gpu("gpu_temp"), _zero, _norm_range),
"network_rx": MetricSpec("network_rx", _read_network_rate, _zero, _norm_rate, _prime_network),
"network_tx": MetricSpec("network_tx", _read_network_rate, _zero, _norm_rate, _prime_network),
}
def get_spec(metric: str) -> Optional[MetricSpec]:
"""Look up the spec for ``metric``, returning ``None`` for unknown names."""
return METRIC_SPECS.get(metric)
@@ -160,9 +160,11 @@ class ProcessedColorStripStream(ColorStripStream):
self._resolve_count = 0
self._resolve_filters()
colors = None
if self._input_stream:
colors = self._input_stream.get_latest_colors()
# Bind to a local first — ``update_source()`` may swap or null
# out ``_input_stream`` between the check and the read on a
# different thread.
inp = self._input_stream
colors = inp.get_latest_colors() if inp is not None else None
if colors is not None and self._filters:
for flt in self._filters:
@@ -38,6 +38,7 @@ from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.storage.asset_store import AssetStore
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.core.weather.weather_manager import WeatherManager
@@ -74,6 +75,7 @@ class ProcessorDependencies:
mqtt_manager: Optional[Any] = None # MQTTManager
game_event_bus: Optional[Any] = None # GameEventBus
audio_processing_template_store: Optional[Any] = None # AudioProcessingTemplateStore
http_endpoint_store: Optional[HTTPEndpointStore] = None
@dataclass
@@ -169,6 +171,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
event_bus=deps.game_event_bus,
audio_processing_template_store=deps.audio_processing_template_store,
sync_clock_manager=deps.sync_clock_manager,
http_endpoint_store=deps.http_endpoint_store,
)
if deps.value_source_store
else None
@@ -0,0 +1,354 @@
"""Single source of truth for value-stream construction.
``ValueStreamManager._create_stream`` used to be a 168-line ``isinstance``
ladder over 14 ``ValueSource`` subclasses with a silent fallback to
``StaticValueStream(value=1.0)``. The ladder forced any new value kind to
edit the factory plus the storage subclass plus the schemas plus the store's
``create_source`` and a missing branch corrupted the stream at runtime.
This module replaces the ladder with a single ``STREAM_BUILDERS`` registry
keyed by the ``source_type`` string (matching the storage layer's
``_VALUE_SOURCE_MAP``). An import-time coverage assertion guarantees the two
registries stay aligned.
Builders take a ``(source, deps: ValueStreamDeps) -> ValueStream`` shape so
both the production manager and any preview / test harness can populate the
deps from their own context.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Optional
if TYPE_CHECKING:
# Typed forward references so mypy/pyright catch typos like
# ``d.gradient_stroe`` at static-analysis time. At runtime these are
# ``Any`` — the live objects come from the FastAPI / manager wiring.
from ledgrab.core.audio.audio_capture_manager import AudioCaptureManager
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.core.processing.color_strip_stream_manager import (
ColorStripStreamManager,
)
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
from ledgrab.core.processing.sync_clock_manager import SyncClockRuntime
from ledgrab.storage.audio_processing_template_store import (
AudioProcessingTemplateStore,
)
from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.storage.audio_template_store import AudioTemplateStore
from ledgrab.storage.gradient_store import GradientStore
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
@dataclass(frozen=True)
class ValueStreamDeps:
"""Dependency bag for value-stream construction.
Builders only read the subset they need. ``value_stream_manager`` is the
one truly load-bearing field it is referenced by
``GradientMapValueStream`` so it can recursively acquire its source
value stream.
``clock_runtime`` is pre-acquired by the manager exclusively for the
``animated_color`` kind (see :data:`NEEDS_CLOCK_RUNTIME`). The manager
owns the bookkeeping for tracking ``vs_id clock_id`` so the builder
stays a pure ``(source, deps) -> stream`` mapping. If a new kind ever
grows a clock dependency, add it to ``NEEDS_CLOCK_RUNTIME`` AND surface
a separate field sharing ``clock_runtime`` across kinds invites the
wrong runtime being passed to the wrong builder.
Field types are quoted so they stay informational under
``TYPE_CHECKING`` and the dataclass still accepts plain mocks at
runtime. Builders therefore get IDE/lint help against typos like
``d.gradient_stroe`` while production code remains duck-typed.
"""
value_stream_manager: "Any"
audio_capture_manager: Optional["AudioCaptureManager"] = None
audio_source_store: Optional["AudioSourceStore"] = None
audio_template_store: Optional["AudioTemplateStore"] = None
audio_processing_template_store: Optional["AudioProcessingTemplateStore"] = None
live_stream_manager: Optional["LiveStreamManager"] = None
ha_manager: Optional["HomeAssistantManager"] = None
gradient_store: Optional["GradientStore"] = None
css_stream_manager: Optional["ColorStripStreamManager"] = None
event_bus: Optional["GameEventBus"] = None
http_endpoint_store: Optional["HTTPEndpointStore"] = None
clock_runtime: Optional["SyncClockRuntime"] = None
# ---------------------------------------------------------------------------
# Per-kind builders
# ---------------------------------------------------------------------------
def _build_static(source, _d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import StaticValueStream
return StaticValueStream(value=source.value)
def _build_animated(source, _d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import AnimatedValueStream
return AnimatedValueStream(
waveform=source.waveform,
speed=source.speed,
min_value=source.min_value,
max_value=source.max_value,
)
def _build_audio(source, d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import AudioValueStream
return AudioValueStream(
audio_source_id=source.audio_source_id,
mode=source.mode,
sensitivity=source.sensitivity,
smoothing=source.smoothing,
min_value=source.min_value,
max_value=source.max_value,
auto_gain=source.auto_gain,
audio_capture_manager=d.audio_capture_manager,
audio_source_store=d.audio_source_store,
audio_template_store=d.audio_template_store,
audio_processing_template_store=d.audio_processing_template_store,
)
def _build_daylight(source, _d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import DaylightValueStream
return DaylightValueStream(
speed=source.speed,
use_real_time=source.use_real_time,
latitude=source.latitude,
longitude=source.longitude,
min_value=source.min_value,
max_value=source.max_value,
)
def _build_adaptive_time(source, _d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import TimeOfDayValueStream
return TimeOfDayValueStream(
schedule=source.schedule,
min_value=source.min_value,
max_value=source.max_value,
)
def _build_adaptive_scene(source, d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import SceneValueStream
return SceneValueStream(
picture_source_id=source.picture_source_id,
scene_behavior=source.scene_behavior,
sensitivity=source.sensitivity,
smoothing=source.smoothing,
min_value=source.min_value,
max_value=source.max_value,
live_stream_manager=d.live_stream_manager,
)
def _build_static_color(source, _d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import StaticColorValueStream
return StaticColorValueStream(color=source.color)
def _build_animated_color(source, d: ValueStreamDeps):
# See NEEDS_CLOCK_RUNTIME below: ``d.clock_runtime`` is pre-acquired by
# ``ValueStreamManager._create_stream`` exclusively for this kind. Any
# other builder that ever needs a clock should add its own deps field
# rather than read this one.
from ledgrab.core.processing.value_stream import AnimatedColorValueStream
return AnimatedColorValueStream(
colors=source.colors,
speed=source.speed,
easing=source.easing,
clock=d.clock_runtime,
)
def _build_adaptive_time_color(source, _d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import AdaptiveTimeColorValueStream
return AdaptiveTimeColorValueStream(schedule=source.schedule)
def _build_ha_entity(source, d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import HAEntityValueStream
return HAEntityValueStream(
ha_source_id=source.ha_source_id,
entity_id=source.entity_id,
attribute=source.attribute,
min_ha_value=source.min_ha_value,
max_ha_value=source.max_ha_value,
smoothing=source.smoothing,
ha_manager=d.ha_manager,
)
def _build_gradient_map(source, d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import GradientMapValueStream
return GradientMapValueStream(
value_source_id=source.value_source_id,
gradient_id=source.gradient_id,
easing=source.easing,
value_stream_manager=d.value_stream_manager,
gradient_store=d.gradient_store,
)
def _build_css_extract(source, d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import CSSExtractValueStream
return CSSExtractValueStream(
color_strip_source_id=source.color_strip_source_id,
led_start=source.led_start,
led_end=source.led_end,
css_stream_manager=d.css_stream_manager,
)
def _build_system_metrics(source, _d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import SystemMetricsValueStream
return SystemMetricsValueStream(
metric=source.metric,
min_value=source.min_value,
max_value=source.max_value,
max_rate=source.max_rate,
disk_path=source.disk_path,
sensor_label=source.sensor_label,
poll_interval=source.poll_interval,
smoothing=source.smoothing,
)
def _build_game_event(source, d: ValueStreamDeps):
# Late import: ``GameEventValueStream`` lives in a separate sub-package
# to keep the game-event subsystem (which transitively pulls in the
# game-integration adapters) off the cold-start path for installs that
# never use game events.
from ledgrab.core.value_sources.game_event_value_source import GameEventValueStream
return GameEventValueStream(
event_type=source.event_type,
min_game_value=source.min_game_value,
max_game_value=source.max_game_value,
smoothing=source.smoothing,
default_value=source.default_value,
timeout=source.timeout,
event_bus=d.event_bus,
)
def _build_http(source, d: ValueStreamDeps):
from ledgrab.core.processing.value_stream import HTTPValueStream
return HTTPValueStream(
endpoint_id=source.http_endpoint_id,
json_path=source.json_path,
interval_s=source.interval_s,
min_value=source.min_value,
max_value=source.max_value,
smoothing=source.smoothing,
http_endpoint_store=d.http_endpoint_store,
)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
StreamBuilder = Callable[[Any, ValueStreamDeps], Any]
STREAM_BUILDERS: dict[str, StreamBuilder] = {
"static": _build_static,
"animated": _build_animated,
"audio": _build_audio,
"daylight": _build_daylight,
"adaptive_time": _build_adaptive_time,
"adaptive_scene": _build_adaptive_scene,
"static_color": _build_static_color,
"animated_color": _build_animated_color,
"adaptive_time_color": _build_adaptive_time_color,
"ha_entity": _build_ha_entity,
"gradient_map": _build_gradient_map,
"css_extract": _build_css_extract,
"system_metrics": _build_system_metrics,
"game_event": _build_game_event,
"http": _build_http,
}
# ``animated_color`` is the only kind that needs a pre-acquired SyncClockRuntime
# from the manager; the rest derive everything they need from ``source`` and
# ``deps``. Exposing this set lets the manager perform the side-effecting
# acquisition step exactly once, before delegating to the registry.
NEEDS_CLOCK_RUNTIME: frozenset[str] = frozenset({"animated_color"})
def build_stream(source, deps: ValueStreamDeps):
"""Build a ValueStream for *source*.
Raises ``ValueError`` when no builder is registered for
``source.source_type``. Coverage is asserted at module import, so this
only fires for an in-flight instance whose ``source_type`` somehow
drifted from the registered set.
"""
builder = STREAM_BUILDERS.get(source.source_type)
if builder is None:
raise ValueError(
f"No value-stream builder for source_type {source.source_type!r} "
f"(id={getattr(source, 'id', '?')!r})"
)
return builder(source, deps)
def _assert_value_kind_coverage() -> None:
"""Verify the registry and storage's ``_VALUE_SOURCE_MAP`` agree.
Runs at module import. Symmetric: every kind in storage must have a
builder, and every builder must correspond to a real storage kind.
Also asserts ``NEEDS_CLOCK_RUNTIME`` only names kinds that exist in the
registry, so a typo there fails the boot loudly instead of silently
leaking a ``SyncClockRuntime`` acquisition.
"""
from ledgrab.storage.value_source import _VALUE_SOURCE_MAP
storage_kinds = set(_VALUE_SOURCE_MAP.keys())
builder_kinds = set(STREAM_BUILDERS.keys())
missing = storage_kinds - builder_kinds
extra = builder_kinds - storage_kinds
if missing or extra:
problems = []
if missing:
problems.append(f"missing builders: {sorted(missing)}")
if extra:
problems.append(f"unregistered kinds: {sorted(extra)}")
raise RuntimeError(
"value_kinds.STREAM_BUILDERS is out of sync with storage._VALUE_SOURCE_MAP: "
+ "; ".join(problems)
)
rogue_clock_kinds = NEEDS_CLOCK_RUNTIME - builder_kinds
if rogue_clock_kinds:
raise RuntimeError(
"value_kinds.NEEDS_CLOCK_RUNTIME names kinds with no registered "
f"builder: {sorted(rogue_clock_kinds)}"
)
_assert_value_kind_coverage()
+284 -296
View File
@@ -21,7 +21,9 @@ ValueStreamManager owns all running ValueStreams, keyed by
from __future__ import annotations
import asyncio
import json
import math
import re
import time
from abc import ABC, abstractmethod
from datetime import datetime
@@ -29,8 +31,14 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
import numpy as np
from ledgrab.core.processing import metric_readers as _metric_readers
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
# Compiled once — used by ``_extract_simple_path`` on every poll.
_NAME_HEAD_RE = re.compile(r"^([^\[]*)")
_INDEX_RE = re.compile(r"^\[(\d+)\]")
if TYPE_CHECKING:
from ledgrab.core.audio.audio_capture import AudioCaptureManager
from ledgrab.core.game_integration.event_bus import GameEventBus
@@ -39,6 +47,7 @@ if TYPE_CHECKING:
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.storage.value_source import ValueSource
from ledgrab.storage.value_source_store import ValueSourceStore
@@ -1017,6 +1026,211 @@ class HAEntityValueStream(ValueStream):
logger.warning("HAEntityValueStream failed to swap HA runtime: %s", e)
# ---------------------------------------------------------------------------
# HTTP poll
# ---------------------------------------------------------------------------
class HTTPValueStream(ValueStream):
"""Periodically polls an HTTPEndpoint and extracts a value via json_path.
Exposes two accessors:
- ``get_value()`` returns a normalized float in [0, 1] for use as a
modulator (brightness, color, etc.). The raw extracted value is
coerced to float; non-numeric values yield 0.0.
- ``get_raw_value()`` returns the un-normalized extracted value
(str / int / float / bool / None) for consumers that need it
verbatim e.g. an automation rule comparing ``"playing"``.
"""
def __init__(
self,
endpoint_id: str,
json_path: str,
interval_s: int,
min_value: float,
max_value: float,
smoothing: float,
http_endpoint_store: Optional["HTTPEndpointStore"] = None,
) -> None:
self._endpoint_id = endpoint_id
self._json_path = json_path
self._interval_s = max(1, int(interval_s))
self._min_value = min_value
self._max_value = max_value
self._smoothing = smoothing
self._http_endpoint_store = http_endpoint_store
self._task: Optional[asyncio.Task] = None
self._raw_value: Any = None
self._prev_normalized: Optional[float] = None
# Kept as private attrs for internal/log diagnostics; not exposed via
# public properties or API until a status endpoint consumes them.
self._last_fetched_at: Optional[datetime] = None
self._last_status_code: Optional[int] = None
self._last_error: Optional[str] = None
def start(self) -> None:
if self._task is not None:
return
if not self._endpoint_id or self._http_endpoint_store is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop — can't poll. Construction in a sync test
# context is fine; the stream just stays idle until started
# from an async context.
return
self._task = loop.create_task(self._poll_loop())
def stop(self) -> None:
task = self._task
self._task = None
if task is not None:
task.cancel()
def get_value(self) -> float:
raw = self._raw_value
if raw is None:
return self._prev_normalized if self._prev_normalized is not None else 0.0
try:
numeric = float(raw)
except (TypeError, ValueError):
return self._prev_normalized if self._prev_normalized is not None else 0.0
rng = self._max_value - self._min_value
if abs(rng) < 1e-9:
normalized = 0.5
else:
normalized = (numeric - self._min_value) / rng
normalized = max(0.0, min(1.0, normalized))
if self._smoothing > 0.0 and self._prev_normalized is not None:
normalized = (
self._smoothing * self._prev_normalized + (1.0 - self._smoothing) * normalized
)
self._prev_normalized = normalized
return normalized
def get_raw_value(self) -> Any:
"""Return the last raw extracted value (string, int, float, etc.)."""
return self._raw_value
def update_source(self, source: "ValueSource") -> None:
from ledgrab.storage.value_source import HTTPValueSource
if not isinstance(source, HTTPValueSource):
return
self._endpoint_id = source.http_endpoint_id
self._json_path = source.json_path
self._interval_s = max(1, int(source.interval_s))
self._min_value = source.min_value
self._max_value = source.max_value
self._smoothing = source.smoothing
async def _poll_loop(self) -> None:
from ledgrab.utils.safe_source import safe_request_bounded
try:
while True:
try:
endpoint = self._http_endpoint_store.get(self._endpoint_id)
except EntityNotFoundError:
# The endpoint was deleted out from under us. Stop the
# poll task so it doesn't spin forever; the next entity
# event (or the value source being deleted) will tidy
# the rest of the bookkeeping.
logger.warning(
"HTTPValueStream stopping: endpoint %s no longer exists",
self._endpoint_id,
)
self._last_error = "endpoint_deleted"
self._raw_value = None
self._task = None
return
except Exception as exc:
self._last_error = f"Endpoint lookup failed: {type(exc).__name__}"
self._raw_value = None
await asyncio.sleep(self._interval_s)
continue
headers = endpoint.build_request_headers()
try:
status, body_bytes, error = await safe_request_bounded(
endpoint.method,
endpoint.url,
headers=headers,
timeout=endpoint.timeout_s,
)
except Exception as exc:
# safe_request_bounded raises HTTPException on URL
# validation failure; treat that as a recoverable poll
# error and try again next cycle.
self._last_status_code = None
self._last_error = f"URL validation failed: {type(exc).__name__}"
self._raw_value = None
await asyncio.sleep(self._interval_s)
continue
self._last_status_code = status if status else None
self._last_error = error
if not error and status:
try:
body_text = body_bytes.decode("utf-8", errors="replace")
except Exception:
body_text = ""
body_json: Any
try:
body_json = json.loads(body_text) if body_text else None
except (ValueError, TypeError):
body_json = None
self._raw_value = _extract_simple_path(body_json, self._json_path, body_text)
else:
self._raw_value = None
self._last_fetched_at = datetime.now()
await asyncio.sleep(self._interval_s)
except asyncio.CancelledError:
raise
def _extract_simple_path(body_json: Any, path: str, body_text: str) -> Any:
"""Extract a value via a dot-path (with optional ``[N]`` indices).
Uses module-level compiled regexes so repeated polls don't recompile.
Returns ``None`` when the path doesn't resolve — runtime callers just
need "the value, or nothing." Empty path returns the raw body text so
plain-text endpoints work too.
"""
if not path:
return body_text or None
if body_json is None:
return None
current: Any = body_json
for raw_segment in path.split("."):
segment = raw_segment.strip()
if not segment:
continue
name_match = _NAME_HEAD_RE.match(segment)
name_part = name_match.group(1) if name_match else ""
remainder = segment[len(name_part) :]
if name_part:
if not isinstance(current, dict) or name_part not in current:
return None
current = current[name_part]
while remainder:
idx_match = _INDEX_RE.match(remainder)
if not idx_match:
return None
idx = int(idx_match.group(1))
if not isinstance(current, list) or idx < 0 or idx >= len(current):
return None
current = current[idx]
remainder = remainder[idx_match.end() :]
return current
# ---------------------------------------------------------------------------
# Gradient Map
# ---------------------------------------------------------------------------
@@ -1313,19 +1527,11 @@ class SystemMetricsValueStream(ValueStream):
self._psutil = None
def start(self) -> None:
if self._psutil is None:
return
# Prime cpu_percent so the first real call returns meaningful data
if self._metric == "cpu_load":
self._psutil.cpu_percent(interval=None)
# Prime network counters
if self._metric in ("network_rx", "network_tx"):
counters = self._psutil.net_io_counters()
if counters:
self._prev_net_bytes = (
counters.bytes_recv if self._metric == "network_rx" else counters.bytes_sent
)
self._prev_net_time = time.monotonic()
# Per-metric priming (e.g. seed cpu_percent or capture an initial
# network counter) lives on the MetricSpec, keyed by ``self._metric``.
spec = _metric_readers.get_spec(self._metric)
if spec is not None and spec.prime is not None:
spec.prime(self)
def stop(self) -> None:
self._prev_value = None
@@ -1357,154 +1563,29 @@ class SystemMetricsValueStream(ValueStream):
return self._raw_value
def _normalize(self, raw: float) -> float:
"""Normalize raw value to [0, 1]."""
if self._metric in ("cpu_load", "ram_usage", "gpu_load", "battery_level", "disk_usage"):
return max(0.0, min(1.0, raw / 100.0))
elif self._metric in ("cpu_temp", "gpu_temp", "fan_speed"):
rng = self._max_val - self._min_val
if abs(rng) < 1e-9:
return 0.5
return max(0.0, min(1.0, (raw - self._min_val) / rng))
elif self._metric in ("network_rx", "network_tx"):
if self._max_rate <= 0:
return 0.5
return max(0.0, min(1.0, raw / self._max_rate))
return 0.0
"""Normalize raw value to [0, 1] via the metric's registered normaliser."""
spec = _metric_readers.get_spec(self._metric)
if spec is None:
return 0.0
return spec.normalize(self, raw)
def _read_metric(self) -> float:
"""Read the raw metric value from the system.
"""Read the raw metric value via the registered reader.
When psutil is unavailable (Android), falls back to the
platform-aware MetricsProvider for cpu/memory and returns 0.0
for desktop-only metrics.
When psutil is unavailable (Android), the spec's ``read_fallback``
path is used desktop-only sensors return 0.0 there. Read errors
are swallowed and the last cached raw value is returned.
"""
spec = _metric_readers.get_spec(self._metric)
if spec is None:
return 0.0
reader = spec.read_psutil if self._psutil is not None else spec.read_fallback
try:
if self._psutil is not None:
return self._read_metric_psutil()
return self._read_metric_fallback()
return reader(self)
except Exception as e:
logger.debug("SystemMetricsValueStream read error (%s): %s", self._metric, e)
return self._raw_value if self._raw_value is not None else 0.0
def _read_metric_psutil(self) -> float:
"""Read metrics via psutil (desktop path)."""
psutil = self._psutil
if self._metric == "cpu_load":
return psutil.cpu_percent(interval=None)
elif self._metric == "ram_usage":
return psutil.virtual_memory().percent
elif self._metric == "disk_usage":
return psutil.disk_usage(self._disk_path).percent
elif self._metric == "battery_level":
bat = psutil.sensors_battery()
return bat.percent if bat else 0.0
elif self._metric == "cpu_temp":
return self._read_cpu_temp()
elif self._metric == "fan_speed":
return self._read_fan_speed()
elif self._metric in ("gpu_load", "gpu_temp"):
return self._read_gpu_metric()
elif self._metric in ("network_rx", "network_tx"):
return self._read_network_rate()
return 0.0
def _read_metric_fallback(self) -> float:
"""Read metrics without psutil (Android / fallback path).
Uses the MetricsProvider abstraction for cpu/memory. Sensors,
battery, network, disk, and GPU are not available.
"""
from ledgrab.utils.metrics import get_metrics_provider
provider = get_metrics_provider()
if self._metric == "cpu_load":
return provider.cpu_percent()
elif self._metric == "ram_usage":
mem = provider.virtual_memory()
if mem.total_bytes > 0:
return (mem.used_bytes / mem.total_bytes) * 100.0
return 0.0
return 0.0
def _read_cpu_temp(self) -> float:
psutil = self._psutil
if psutil is None:
return 0.0
temps = psutil.sensors_temperatures()
if not temps:
return 0.0
# If sensor_label specified, try to find it
if self._sensor_label:
for group_name, entries in temps.items():
for entry in entries:
if entry.label == self._sensor_label or group_name == self._sensor_label:
return entry.current
# Fallback: first available sensor
for entries in temps.values():
if entries:
return entries[0].current
return 0.0
def _read_fan_speed(self) -> float:
psutil = self._psutil
if psutil is None:
return 0.0
fans = psutil.sensors_fans()
if not fans:
return 0.0
if self._sensor_label:
for group_name, entries in fans.items():
for entry in entries:
if entry.label == self._sensor_label or group_name == self._sensor_label:
return entry.current
# Fallback: first available fan
for entries in fans.values():
if entries:
return entries[0].current
return 0.0
def _read_gpu_metric(self) -> float:
if self._gpu_unavailable:
return 0.0
try:
from ledgrab.utils.gpu import nvml, nvml_available, nvml_handle
if not nvml_available or nvml_handle is None:
self._gpu_unavailable = True
return 0.0
if self._metric == "gpu_load":
util = nvml.nvmlDeviceGetUtilizationRates(nvml_handle)
return float(util.gpu)
else: # gpu_temp
return float(nvml.nvmlDeviceGetTemperature(nvml_handle, 0))
except Exception as e:
logger.debug("GPU metric read error: %s", e)
self._gpu_unavailable = True
return 0.0
def _read_network_rate(self) -> float:
psutil = self._psutil
if psutil is None:
return 0.0
counters = psutil.net_io_counters()
if not counters:
return 0.0
current_bytes = counters.bytes_recv if self._metric == "network_rx" else counters.bytes_sent
now = time.monotonic()
if self._prev_net_bytes is None or self._prev_net_time is None:
self._prev_net_bytes = current_bytes
self._prev_net_time = now
return 0.0
dt = now - self._prev_net_time
if dt <= 0:
return 0.0
# Cap delta time to avoid spikes after long gaps
dt = min(dt, self._poll_interval * 2)
rate = (current_bytes - self._prev_net_bytes) / dt
self._prev_net_bytes = current_bytes
self._prev_net_time = now
return max(0.0, rate)
def update_source(self, source: "ValueSource") -> None:
from ledgrab.storage.value_source import SystemMetricsValueSource
@@ -1547,6 +1628,7 @@ class ValueStreamManager:
event_bus: Optional["GameEventBus"] = None,
audio_processing_template_store=None,
sync_clock_manager: Optional["SyncClockManager"] = None,
http_endpoint_store: Optional["HTTPEndpointStore"] = None,
):
self._value_source_store = value_source_store
self._audio_capture_manager = audio_capture_manager
@@ -1559,6 +1641,7 @@ class ValueStreamManager:
self._event_bus = event_bus
self._audio_processing_template_store = audio_processing_template_store
self._sync_clock_manager = sync_clock_manager
self._http_endpoint_store = http_endpoint_store
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
# Tracks which clock_id (if any) was acquired for each stream so we
@@ -1602,6 +1685,17 @@ class ValueStreamManager:
else:
logger.info(f"Released ref for value stream {vs_id} (refs={refs})")
def peek(self, vs_id: str) -> Optional[ValueStream]:
"""Read-only accessor: return the running ValueStream for ``vs_id``
if one exists, else ``None``.
Does NOT change ref counts. Use for consumer-driven reads where the
caller already holds a reference via :meth:`acquire` (e.g. the
:class:`AutomationEngine` evaluating an ``HTTPPollRule`` against a
value source it has already acquired in ``_sync_value_stream_refs``).
"""
return self._streams.get(vs_id)
def update_source(self, vs_id: str) -> None:
"""Hot-update the shared stream for the given ValueSource."""
try:
@@ -1699,158 +1793,52 @@ class ValueStreamManager:
logger.info("Released all value streams")
def _create_stream(self, source: "ValueSource", vs_id: Optional[str] = None) -> ValueStream:
"""Factory: create the appropriate ValueStream for a ValueSource."""
from ledgrab.storage.value_source import (
AdaptiveValueSource,
AnimatedValueSource,
AudioValueSource,
CSSExtractValueSource,
DaylightValueSource,
GameEventValueSource,
GradientMapValueSource,
HAEntityValueSource,
StaticValueSource,
StaticColorValueSource,
AnimatedColorValueSource,
AdaptiveTimeColorValueSource,
SystemMetricsValueSource,
"""Build a ValueStream for *source* via the central kind registry.
The 14-branch ``isinstance`` ladder this method used to host was the
canonical example of the parallel-change smell flagged in the
architecture audit. The actual per-kind construction now lives in
``ledgrab.core.processing.value_kinds.STREAM_BUILDERS``, keyed by
``source.source_type``. This method only handles the manager-side
bookkeeping that does not fit a uniform builder signature namely
the optional :class:`SyncClockRuntime` acquisition for
``animated_color`` sources, whose ``vs_id clock_id`` mapping the
manager owns for symmetric release at teardown.
"""
from ledgrab.core.processing.value_kinds import (
NEEDS_CLOCK_RUNTIME,
ValueStreamDeps,
build_stream,
)
if isinstance(source, StaticValueSource):
return StaticValueStream(value=source.value)
if isinstance(source, AnimatedValueSource):
return AnimatedValueStream(
waveform=source.waveform,
speed=source.speed,
min_value=source.min_value,
max_value=source.max_value,
)
if isinstance(source, AudioValueSource):
return AudioValueStream(
audio_source_id=source.audio_source_id,
mode=source.mode,
sensitivity=source.sensitivity,
smoothing=source.smoothing,
min_value=source.min_value,
max_value=source.max_value,
auto_gain=source.auto_gain,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=self._audio_source_store,
audio_template_store=self._audio_template_store,
audio_processing_template_store=self._audio_processing_template_store,
)
if isinstance(source, DaylightValueSource):
return DaylightValueStream(
speed=source.speed,
use_real_time=source.use_real_time,
latitude=source.latitude,
longitude=source.longitude,
min_value=source.min_value,
max_value=source.max_value,
)
if isinstance(source, AdaptiveValueSource):
if source.source_type == "adaptive_scene":
return SceneValueStream(
picture_source_id=source.picture_source_id,
scene_behavior=source.scene_behavior,
sensitivity=source.sensitivity,
smoothing=source.smoothing,
min_value=source.min_value,
max_value=source.max_value,
live_stream_manager=self._live_stream_manager,
)
return TimeOfDayValueStream(
schedule=source.schedule,
min_value=source.min_value,
max_value=source.max_value,
)
# Color streams
if isinstance(source, StaticColorValueSource):
return StaticColorValueStream(color=source.color)
if isinstance(source, AnimatedColorValueSource):
clock_runtime = None
if source.clock_id and self._sync_clock_manager:
clock_runtime = None
if source.source_type in NEEDS_CLOCK_RUNTIME:
clock_id = getattr(source, "clock_id", None)
if clock_id and self._sync_clock_manager:
try:
clock_runtime = self._sync_clock_manager.acquire(source.clock_id)
clock_runtime = self._sync_clock_manager.acquire(clock_id)
if vs_id is not None:
self._stream_clock_ids[vs_id] = source.clock_id
self._stream_clock_ids[vs_id] = clock_id
except Exception as e:
logger.warning(
"Could not acquire sync clock %s for value source %s: %s",
source.clock_id,
clock_id,
source.id,
e,
)
return AnimatedColorValueStream(
colors=source.colors,
speed=source.speed,
easing=source.easing,
clock=clock_runtime,
)
if isinstance(source, AdaptiveTimeColorValueSource):
return AdaptiveTimeColorValueStream(schedule=source.schedule)
if isinstance(source, HAEntityValueSource):
return HAEntityValueStream(
ha_source_id=source.ha_source_id,
entity_id=source.entity_id,
attribute=source.attribute,
min_ha_value=source.min_ha_value,
max_ha_value=source.max_ha_value,
smoothing=source.smoothing,
ha_manager=self._ha_manager,
)
if isinstance(source, GradientMapValueSource):
return GradientMapValueStream(
value_source_id=source.value_source_id,
gradient_id=source.gradient_id,
easing=source.easing,
value_stream_manager=self,
gradient_store=self._gradient_store,
)
if isinstance(source, CSSExtractValueSource):
return CSSExtractValueStream(
color_strip_source_id=source.color_strip_source_id,
led_start=source.led_start,
led_end=source.led_end,
css_stream_manager=self._css_stream_manager,
)
if isinstance(source, SystemMetricsValueSource):
return SystemMetricsValueStream(
metric=source.metric,
min_value=source.min_value,
max_value=source.max_value,
max_rate=source.max_rate,
disk_path=source.disk_path,
sensor_label=source.sensor_label,
poll_interval=source.poll_interval,
smoothing=source.smoothing,
)
if isinstance(source, GameEventValueSource):
from ledgrab.core.value_sources.game_event_value_source import (
GameEventValueStream,
)
return GameEventValueStream(
event_type=source.event_type,
min_game_value=source.min_game_value,
max_game_value=source.max_game_value,
smoothing=source.smoothing,
default_value=source.default_value,
timeout=source.timeout,
event_bus=self._event_bus,
)
# Fallback
return StaticValueStream(value=1.0)
deps = ValueStreamDeps(
value_stream_manager=self,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=self._audio_source_store,
audio_template_store=self._audio_template_store,
audio_processing_template_store=self._audio_processing_template_store,
live_stream_manager=self._live_stream_manager,
ha_manager=self._ha_manager,
gradient_store=self._gradient_store,
css_stream_manager=self._css_stream_manager,
event_bus=self._event_bus,
http_endpoint_store=self._http_endpoint_store,
clock_runtime=clock_runtime,
)
return build_stream(source, deps)
@@ -37,6 +37,33 @@ def is_youtube_url(url: str) -> bool:
return any(p.search(url) for p in _YT_PATTERNS)
# Network schemes accepted by ``cv2.VideoCapture``. ``file://`` is intentionally
# excluded — local files are supported via plain path strings (no scheme), so
# explicit ``file://`` requests can only be an attempt to coerce FFmpeg into
# loading something the path-string code path would reject. ``concat:``,
# ``gopher://``, ``crypto:``, etc. are not allowed.
_ALLOWED_NETWORK_SCHEMES: tuple[str, ...] = ("http", "https", "rtsp", "rtsps")
def _assert_video_url_allowed(url: str) -> None:
"""Reject video URLs that use anything other than a vetted scheme.
OpenCV/FFmpeg supports many esoteric input protocols (``concat:``,
``gopher://``, ``crypto:``, ``udp://``, ``async:``, ). Some can read
arbitrary host files or pivot to internal addresses when the caller
can influence the URL. Tighten the input to the schemes we actually
advertise. URLs without a scheme are accepted as local-file paths.
"""
if "://" not in url:
return # plain local path — OpenCV resolves against the working dir
scheme = url.split("://", 1)[0].lower()
if scheme not in _ALLOWED_NETWORK_SCHEMES:
raise RuntimeError(
f"Refusing to open video with unsupported scheme {scheme!r}; "
f"allowed: {', '.join(_ALLOWED_NETWORK_SCHEMES)} or a local file path."
)
def resolve_youtube_url(url: str, resolution_limit: Optional[int] = None) -> str:
"""Resolve a YouTube URL to a direct stream URL using yt-dlp."""
try:
@@ -185,10 +212,14 @@ class VideoCaptureLiveStream(LiveStream):
if self._running:
return
# Resolve YouTube URL if needed
# Resolve YouTube URL if needed. Validate AFTER resolution too, so a
# malicious yt-dlp result (or a redirect we don't expect) can't slip
# through with an unsupported scheme.
actual_url = self._original_url
_assert_video_url_allowed(actual_url)
if is_youtube_url(actual_url):
actual_url = resolve_youtube_url(actual_url, self._resolution_limit)
_assert_video_url_allowed(actual_url)
self._resolved_url = actual_url
# Open capture
@@ -224,7 +224,8 @@ class WledTargetProcessor(TargetProcessor):
self._is_running = False
# Cancel task
# Cancel task. The cancellation is awaited above, so the prior
# 50 ms ``asyncio.sleep`` here was pure dead time on every stop().
if self._task:
self._task.cancel()
try:
@@ -233,7 +234,6 @@ class WledTargetProcessor(TargetProcessor):
logger.debug("WLED target processor task cancelled")
pass
self._task = None
await asyncio.sleep(0.05)
# Restore device state (only if auto_shutdown is enabled)
if self._led_client and self._device_state_before:
@@ -14,6 +14,7 @@ from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from ledgrab.core.processing.light_target_helpers import swap_color_source
from ledgrab.core.processing.target_processor import TargetContext, TargetProcessor
from ledgrab.storage.z2m_light_output_target import (
DEFAULT_Z2M_BASE_TOPIC,
@@ -270,45 +271,12 @@ class Z2MLightTargetProcessor(TargetProcessor):
logger.warning(f"Z2M light {self._target_id}: CSS swap failed: {e}")
def _swap_color_source(self, new_kind: str, new_color_vs_id: str) -> None:
if self._is_running:
if self._css_stream and self._ctx.color_strip_stream_manager:
try:
self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
except Exception:
pass
self._css_stream = None
if self._color_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._color_vs_id)
except Exception:
pass
self._color_stream = None
self._source_kind = new_kind
self._color_vs_id = new_color_vs_id
"""Release the previous colour stream and acquire the new one."""
swap_color_source(self, new_kind, new_color_vs_id, log_label="Z2M light")
# Reset per-entity history so the new source isn't gated by stale values.
self._previous_colors.clear()
self._previous_on.clear()
if not self._is_running:
return
if self._source_kind == "color_vs":
if self._color_vs_id and self._ctx.value_stream_manager:
try:
self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
except Exception as e:
logger.warning(f"Z2M light {self._target_id}: failed to acquire color VS: {e}")
else:
if self._css_id and self._ctx.color_strip_stream_manager:
try:
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
self._css_id, self._target_id
)
except Exception as e:
logger.warning(
f"Z2M light {self._target_id}: failed to re-acquire CSS stream: {e}"
)
# ─────────── WebSocket clients ───────────
def add_ws_client(self, ws: Any) -> None:
+102 -28
View File
@@ -5,11 +5,13 @@ import hashlib
import os
import re
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
@@ -23,6 +25,7 @@ from ledgrab.core.update.release_provider import AssetInfo, ReleaseInfo, Release
from ledgrab.core.update.version_check import is_newer, normalize_version
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import is_blocked_for_ssrf
logger = get_logger(__name__)
@@ -38,6 +41,44 @@ _SHA256_RE = re.compile(r"\b([a-fA-F0-9]{64})\b")
_STARTUP_DELAY_S = 30
_MANUAL_CHECK_DEBOUNCE_S = 60
# Manual-redirect limits for SSRF-safe update downloads.
_UPDATE_MAX_REDIRECT_HOPS = 5
def _validate_update_url(url: str) -> None:
"""Reject update URLs whose scheme or resolved host is non-public.
The update pipeline fetches release feeds and binaries from
``update.repo_url`` (default: Gitea instance) and may follow
redirects to CDN hosts. Without per-hop validation, a hostile or
compromised feed could redirect the binary download to a private
address (SSRF) or to a non-HTTPS scheme. This guard enforces:
* scheme is ``http`` or ``https``
* hostname is present
* DNS resolution returns no private / loopback / link-local /
multicast / reserved / unparseable address
Raises ``RuntimeError`` (not ``HTTPException`` this code path runs
in a background task, not a request handler).
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise RuntimeError(f"Refusing update URL with unsupported scheme: {parsed.scheme!r}")
hostname = parsed.hostname
if not hostname:
raise RuntimeError("Update URL missing hostname")
try:
infos = socket.getaddrinfo(hostname, None)
except socket.gaierror as exc:
raise RuntimeError(f"Cannot resolve update host: {hostname} ({exc})") from exc
ips = {info[4][0] for info in infos}
for ip in ips:
if is_blocked_for_ssrf(ip):
raise RuntimeError(
f"Refusing update URL: host {hostname!r} resolves to " f"non-public address {ip}"
)
class UpdateService:
"""Periodically polls a ReleaseProvider and fires WebSocket events."""
@@ -250,29 +291,64 @@ class UpdateService:
finally:
self._downloading = False
async def _safe_get_text(self, url: str, timeout: float = 30.0) -> str:
"""Fetch *url* as text with manual, SSRF-validated redirect handling."""
current = url
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
for _ in range(_UPDATE_MAX_REDIRECT_HOPS + 1):
_validate_update_url(current)
resp = await client.get(current)
if resp.is_redirect:
location = resp.headers.get("location")
if not location:
raise RuntimeError("Update redirect without Location header")
current = str(httpx.URL(current).join(location))
continue
resp.raise_for_status()
return resp.text
raise RuntimeError(
f"Too many redirects fetching update text (max {_UPDATE_MAX_REDIRECT_HOPS})"
)
async def _stream_download(self, url: str, dest: Path, total_size: int) -> None:
"""Stream-download a file, updating progress as we go."""
"""Stream-download a file with manual, SSRF-validated redirect handling.
Each hop is re-validated via :func:`_validate_update_url` so a
compromised release feed cannot redirect the binary download to a
non-public address.
"""
tmp = dest.with_suffix(dest.suffix + ".tmp")
received = 0
async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
async with client.stream("GET", url) as resp:
resp.raise_for_status()
with open(tmp, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=65536):
f.write(chunk)
received += len(chunk)
if total_size > 0:
self._download_progress = received / total_size
if self._fire_event:
self._fire_event(
{
"type": "update_download_progress",
"progress": round(self._download_progress, 3),
}
)
# Atomic rename
tmp.replace(dest)
self._download_progress = 1.0
current = url
async with httpx.AsyncClient(timeout=300, follow_redirects=False) as client:
for _ in range(_UPDATE_MAX_REDIRECT_HOPS + 1):
_validate_update_url(current)
async with client.stream("GET", current) as resp:
if resp.is_redirect:
location = resp.headers.get("location")
if not location:
raise RuntimeError("Update redirect without Location header")
current = str(httpx.URL(current).join(location))
continue
resp.raise_for_status()
with open(tmp, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=65536):
f.write(chunk)
received += len(chunk)
if total_size > 0:
self._download_progress = received / total_size
if self._fire_event:
self._fire_event(
{
"type": "update_download_progress",
"progress": round(self._download_progress, 3),
}
)
# Atomic rename
tmp.replace(dest)
self._download_progress = 1.0
return
raise RuntimeError(f"Too many redirects fetching update (max {_UPDATE_MAX_REDIRECT_HOPS})")
# ── Apply ──────────────────────────────────────────────────
@@ -324,20 +400,18 @@ class UpdateService:
if not asset:
return None
# 1) sibling .sha256 asset
# 1) sibling .sha256 asset — fetch with manual, SSRF-validated
# redirects so the checksum can't be sourced from an untrusted host.
sibling = next(
(a for a in release.assets if a.name == f"{asset.name}.sha256"),
None,
)
if sibling:
try:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
resp = await client.get(sibling.download_url)
resp.raise_for_status()
text = resp.text.strip()
match = _SHA256_RE.search(text)
if match:
return match.group(1).lower()
text = await self._safe_get_text(sibling.download_url)
match = _SHA256_RE.search(text.strip())
if match:
return match.group(1).lower()
except Exception as exc:
logger.warning("Failed to fetch sibling sha256 asset: %s", exc)
+110 -38
View File
@@ -1,8 +1,10 @@
"""FastAPI application entry point."""
import asyncio
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Awaitable
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -50,6 +52,7 @@ import ledgrab.core.game_integration.adapters # noqa: F401 — register built-i
from ledgrab.core.game_integration.community_loader import register_community_adapters
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.pattern_template_store import PatternTemplateStore
import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration
@@ -69,6 +72,10 @@ logger = get_logger(__name__)
# Get configuration
config = get_config()
# The shutdown-complete signal is owned by a leaf module so ``__main__``
# can import it without dragging in this module's heavy global state.
from ledgrab.shutdown_state import shutdown_complete # noqa: E402
def _migrate_legacy_data_location() -> None:
"""Rescue data from pre-rename cwd-relative paths.
@@ -166,6 +173,7 @@ ha_store = HomeAssistantStore(db)
ha_manager = HomeAssistantManager(ha_store)
mqtt_source_store = MQTTSourceStore(db)
mqtt_manager = MQTTManager(mqtt_source_store)
http_endpoint_store = HTTPEndpointStore(db)
audio_processing_template_store = AudioProcessingTemplateStore(db)
game_integration_store = GameIntegrationStore(db)
pattern_template_store = PatternTemplateStore(db)
@@ -191,6 +199,7 @@ processor_manager = ProcessorManager(
mqtt_manager=mqtt_manager,
game_event_bus=game_event_bus,
audio_processing_template_store=audio_processing_template_store,
http_endpoint_store=http_endpoint_store,
)
)
@@ -247,7 +256,9 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error("Legacy MQTT migration failed: %s", e)
# Create automation engine (needs processor_manager + MQTT manager + stores for scene activation)
# Create automation engine. HTTPPollRule evaluation reads from a
# ValueStream produced by the ValueStreamManager (which lives inside
# the processor manager), so the engine needs that handle.
automation_engine = AutomationEngine(
automation_store,
processor_manager,
@@ -256,6 +267,8 @@ async def lifespan(app: FastAPI):
device_store=device_store,
ha_manager=ha_manager,
mqtt_manager=mqtt_manager,
value_stream_manager=processor_manager.value_stream_manager,
value_source_store=value_source_store,
)
# Create auto-backup engine — derive paths from database location so that
@@ -309,6 +322,7 @@ async def lifespan(app: FastAPI):
game_event_bus=game_event_bus,
mqtt_store=mqtt_source_store,
mqtt_manager=mqtt_manager,
http_endpoint_store=http_endpoint_store,
audio_processing_template_store=audio_processing_template_store,
pattern_template_store=pattern_template_store,
)
@@ -385,28 +399,39 @@ async def lifespan(app: FastAPI):
yield
# Shutdown
#
# Each step has a strict time budget. Windows gives a GUI app with a
# shutdown-block-reason set ~20 s before it force-terminates the
# process; if any single step stalls (network call to a dead WLED, a
# zombie MQTT broker), we MUST keep moving so the steps that actually
# protect the user's state — device restore frames and the DB
# checkpoint — still get to run.
logger.info("Shutting down LED Grab")
# Persist all stores to disk before stopping anything.
# This ensures in-memory data survives force-kills and restarts
# where no CRUD happened during the session.
_save_all_stores()
async def _bounded(label: str, coro: Awaitable, timeout: float) -> None:
try:
await asyncio.wait_for(coro, timeout=timeout)
except asyncio.TimeoutError:
logger.error("Shutdown step '%s' exceeded %.1fs — moving on", label, timeout)
except Exception as e:
logger.error("Shutdown step '%s' raised: %s", label, e)
# Legacy hook — SQLite stores are write-through so this only logs.
# Durability comes from PRAGMA synchronous=FULL + the explicit
# wal_checkpoint(TRUNCATE) in Database.close() at the end of this block.
try:
_save_all_stores()
except Exception as e:
logger.error(f"Error persisting stores: {e}")
# Stop automation engine first so it can no longer activate scenes that
# would talk to processors mid-shutdown.
try:
await automation_engine.stop()
logger.info("Stopped automation engine")
except Exception as e:
logger.error(f"Error stopping automation engine: {e}")
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
# Stop discovery watcher and OS notification listener so they stop
# firing events into a shutting-down processor manager.
if discovery_watcher is not None:
try:
await discovery_watcher.stop()
except Exception as e:
logger.error(f"Error stopping discovery watcher: {e}")
await _bounded("discovery_watcher.stop", discovery_watcher.stop(), timeout=1.0)
try:
os_notif_listener.stop()
@@ -432,22 +457,18 @@ async def lifespan(app: FastAPI):
action = "stop_targets"
logger.info("Shutdown action: %s", action)
try:
await processor_manager.stop_all(restore_devices=action != "nothing")
logger.info("Stopped all processors")
except Exception as e:
logger.error(f"Error stopping processors: {e}")
# This is the step that *implements* the user's stop_targets setting.
# Give it the largest slice of the budget.
await _bounded(
"processor_manager.stop_all",
processor_manager.stop_all(restore_devices=action != "nothing"),
timeout=8.0,
)
logger.info("Stopped all processors")
# Now safe to tear down the connections that processors depended on.
try:
await ha_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping Home Assistant manager: {e}")
try:
await mqtt_manager.shutdown()
except Exception as e:
logger.error(f"Error stopping MQTT manager: {e}")
await _bounded("ha_manager.shutdown", ha_manager.shutdown(), timeout=1.5)
await _bounded("mqtt_manager.shutdown", mqtt_manager.shutdown(), timeout=1.5)
# Independent services — order doesn't matter relative to processors.
try:
@@ -455,26 +476,37 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"Error stopping weather manager: {e}")
await _bounded("update_service.stop", update_service.stop(), timeout=0.5)
await _bounded("auto_backup_engine.stop", auto_backup_engine.stop(), timeout=0.5)
# Close the DB last so it runs a TRUNCATE checkpoint, flushing the WAL
# into the main file. Without this, writes can survive a graceful app
# restart (Python finalizer checkpoints on GC) but be lost on a later
# unclean PC shutdown — the symptom users see as "my fix reverted after
# rebooting the PC."
try:
await update_service.stop()
db.close()
except Exception as e:
logger.error(f"Error stopping update checker: {e}")
logger.error(f"Error closing database: {e}")
try:
await auto_backup_engine.stop()
except Exception as e:
logger.error(f"Error stopping auto-backup engine: {e}")
# Tell any external supervisor (Windows shutdown guard, tray) that
# cleanup is done so Windows can finish ending the session promptly.
shutdown_complete.set()
logger.info("Shutdown complete")
# Create FastAPI application
# Create FastAPI application. The built-in ``/docs``, ``/redoc``, and
# ``/openapi.json`` routes are disabled here so they can be re-added below
# with an :data:`AuthRequired` dependency — exposing the full OpenAPI surface
# (route paths + parameter schemas) without auth is information disclosure.
app = FastAPI(
title="LED Grab",
description="Control WLED devices based on screen content for ambient lighting",
version=__version__,
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
# Configure CORS
@@ -521,6 +553,46 @@ async def _no_cache_static(request: Request, call_next):
return await call_next(request)
# Middleware: baseline security headers on every response. CSP is intentionally
# omitted here because the UI uses inline event handlers / templates and a
# wrong CSP value would break the app; the other three headers are universally
# safe defaults and close several common browser-side attack vectors.
@app.middleware("http")
async def _security_headers(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=(), payment=()",
)
return response
# ── Auth-gated OpenAPI surface ────────────────────────────────────────────
# Re-add the docs endpoints we disabled above, now protected by the same
# Bearer auth as the rest of the API. When auth is unconfigured, loopback
# clients still get in anonymously (per ``verify_api_key`` policy).
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html # noqa: E402
from ledgrab.api.auth import AuthRequired # noqa: E402
@app.get("/openapi.json", include_in_schema=False)
async def _openapi(_auth: AuthRequired):
return JSONResponse(app.openapi())
@app.get("/docs", include_in_schema=False)
async def _swagger_docs(_auth: AuthRequired):
return get_swagger_ui_html(openapi_url="/openapi.json", title=f"{app.title} — API docs")
@app.get("/redoc", include_in_schema=False)
async def _redoc_docs(_auth: AuthRequired):
return get_redoc_html(openapi_url="/openapi.json", title=f"{app.title} — API docs")
# Mount static files
static_path = Path(__file__).parent / "static"
if static_path.exists():
+18
View File
@@ -0,0 +1,18 @@
"""Cross-thread shutdown completion signal.
This module is intentionally tiny so importing it does not pull in the
heavy global state (Database, stores, processor manager) instantiated at
import time by ``ledgrab.main``. ``__main__`` imports it on the main
thread before uvicorn loads ``ledgrab.main`` in its event-loop thread;
both ends share the same ``threading.Event`` instance.
The lifespan in ``ledgrab.main`` calls ``shutdown_complete.set()`` at the
very end of its teardown sequence (after stopping targets, flushing
stores, and checkpointing the DB). External supervisors the Windows
OS-shutdown guard and the tray's "Shutdown" handler — wait on it so
they release Windows / unblock only once cleanup is genuinely done.
"""
import threading
shutdown_complete: threading.Event = threading.Event()
+309
View File
@@ -5982,3 +5982,312 @@ body.composite-layer-dragging .composite-layer-drag-handle {
.icon-picker-toolbar { flex-direction: column; align-items: stretch; }
}
/* HTTP endpoint editor: custom headers list
Mirrors the .group-child-row vocabulary used by device-groups so
the modal feels native to the rest of the app. Each row is a
bordered card on `--bg-color`, with two input slots and a trash
button on the right; the leading numeric index gives the rows a
sense of order and matches the rack-panel section numbering. */
.http-headers-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
max-height: 320px;
overflow-y: auto;
padding: 2px 0;
}
.http-headers-empty {
color: var(--text-secondary);
font-size: 0.8125rem;
font-style: italic;
text-align: center;
padding: 16px;
border: 1px dashed var(--border-color);
border-radius: 8px;
}
.http-header-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 6px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm, 6px);
background: var(--bg-color);
transition: border-color 0.2s, background 0.15s, box-shadow 0.2s;
}
.http-header-row:hover,
.http-header-row:focus-within {
border-color: color-mix(in srgb, var(--primary-color) 40%, var(--border-color));
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.http-header-index {
font-size: 0.7rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
min-width: 18px;
text-align: center;
opacity: 0.6;
user-select: none;
}
.http-header-fields {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
gap: 6px;
min-width: 0;
}
.http-header-name,
.http-header-value {
width: 100%;
padding: 5px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-bg);
color: var(--text-color);
font-size: 0.8125rem;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
}
.http-header-name {
font-weight: 600;
}
.http-header-name:focus,
.http-header-value:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 25%, transparent);
}
.http-header-remove {
flex-shrink: 0;
width: 28px;
height: 28px;
padding: 0;
opacity: 0.55;
transition: opacity 0.15s, color 0.15s, background 0.15s;
}
.http-header-row:hover .http-header-remove,
.http-header-row:focus-within .http-header-remove {
opacity: 1;
}
.http-header-remove:hover {
color: var(--danger-color);
border-color: color-mix(in srgb, var(--danger-color) 40%, var(--border-color));
background: color-mix(in srgb, var(--danger-color) 10%, transparent);
}
.btn-add-header {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
padding: 6px 12px;
border-radius: 6px;
}
.btn-add-header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 1rem;
line-height: 1;
font-weight: 600;
opacity: 0.8;
}
@media (max-width: 600px) {
.http-header-fields {
grid-template-columns: minmax(0, 1fr);
}
.http-header-index {
align-self: flex-start;
margin-top: 6px;
}
.http-header-row {
align-items: flex-start;
}
.http-header-remove {
align-self: flex-start;
margin-top: 2px;
}
}
/* HTTP endpoint editor: inline test request UI
The Test button sits inside the request section and renders its
response below as a result card. Status badges use the success /
danger tokens to stay consistent with toast colors. */
.http-endpoint-test-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.http-endpoint-test-btn {
display: inline-flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.http-endpoint-test-btn .http-endpoint-test-btn-icon {
display: inline-flex;
align-items: center;
}
.http-endpoint-test-btn .http-endpoint-test-btn-icon .icon {
width: 14px;
height: 14px;
}
.http-endpoint-test-btn.loading {
opacity: 0.7;
pointer-events: none;
}
.http-test-output {
margin-top: 0;
}
.http-test-pending {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--text-secondary);
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-color);
}
.http-test-pending-spinner {
width: 12px;
height: 12px;
border: 2px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: http-test-spin 0.8s linear infinite;
}
@keyframes http-test-spin {
to { transform: rotate(360deg); }
}
.http-test-result {
border: 1px solid var(--border-color);
border-left-width: 3px;
border-radius: 6px;
background: var(--bg-color);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.http-test-result.http-test-ok {
border-left-color: var(--success-color, #28a745);
background: color-mix(in srgb, var(--success-color, #28a745) 6%, var(--bg-color));
}
.http-test-result.http-test-fail {
border-left-color: var(--danger-color, #f44336);
background: color-mix(in srgb, var(--danger-color, #f44336) 6%, var(--bg-color));
}
.http-test-line {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.http-test-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.http-test-badge .icon {
width: 12px;
height: 12px;
}
.http-test-badge-ok {
background: color-mix(in srgb, var(--success-color, #28a745) 18%, transparent);
color: var(--success-color, #28a745);
}
.http-test-badge-fail {
background: color-mix(in srgb, var(--danger-color, #f44336) 18%, transparent);
color: var(--danger-color, #f44336);
}
.http-test-status {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 4px;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.http-test-error {
display: block;
padding: 6px 10px;
border-radius: 4px;
background: color-mix(in srgb, var(--danger-color, #f44336) 10%, var(--card-bg));
color: var(--text-color);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
}
.http-test-body-label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-secondary);
}
.http-test-body {
margin: 0;
padding: 8px 10px;
max-height: 220px;
overflow: auto;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.75rem;
line-height: 1.45;
color: var(--text-color);
white-space: pre;
word-break: normal;
}
+7
View File
@@ -344,6 +344,13 @@ export async function loadServerInfo() {
uptimeSec: data.uptime_seconds,
recordedAtPerf: performance.now(),
};
// Wake the inline transport-uptime ticker immediately instead of
// waiting up to a full second for its setInterval tick. Without
// this nudge the field can stay on "—" for ~1 s after page load
// (and up to 10 s if init's first /health response arrives just
// after a tick and the user has to wait for the next connection-
// monitor poll to seed it).
document.dispatchEvent(new CustomEvent('serverUptimeChanged'));
}
// Demo mode detection
+61 -2
View File
@@ -14,6 +14,55 @@ import { showRestartingOverlay } from './api.ts';
import { logError } from './log.ts';
import { openAuthedWs } from './ws-auth.ts';
/**
* Allowed ``type`` values on inbound server-event messages. Anything outside
* this list is rejected before dispatch so a malformed message can't
* synthesise an arbitrary ``server:*`` CustomEvent. New event types must be
* added here intentionally the server is the schema's source of truth.
*
* Audit (matches Python sources for ``fire_event`` / ``_fire_event`` /
* ``self._emit`` / ``fire_entity_event`` call sites see the parity
* regression test in ``server/tests/test_events_ws_parity.py``):
* server_restarting server_ref.py / update_service.py
* state_change wled_target_processor.py / auto_restart.py
* automation_state_changed automation_engine.py
* entity_changed dependencies.fire_entity_event
* device_health_changed device_health.py
* update_available update_service.py (consumed by features/update.ts)
* update_download_progress update_service.py (consumed by features/update.ts)
* device_discovered discovery_watcher.py (consumed by features/notifications-watcher.ts)
* device_lost discovery_watcher.py (consumed by features/notifications-watcher.ts)
*
* Missing any of these silently breaks the corresponding UI flow keep
* this list in sync when adding new event types on the server side.
*/
const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
'server_restarting',
'state_change',
'automation_state_changed',
'entity_changed',
'device_health_changed',
'update_available',
'update_download_progress',
'device_discovered',
'device_lost',
]);
interface ServerEventEnvelope {
type: string;
[key: string]: unknown;
}
function _isServerEventEnvelope(value: unknown): value is ServerEventEnvelope {
if (!value || typeof value !== 'object') return false;
const t = (value as { type?: unknown }).type;
if (typeof t !== 'string' || !_ALLOWED_SERVER_EVENT_TYPES.has(t)) return false;
// Event-name character set: identifiers only. CustomEvent names can be
// anything but pinning them keeps the listener namespace predictable.
if (!/^[a-zA-Z0-9_]+$/.test(t)) return false;
return true;
}
/** True when the server has signalled it is restarting (not crashed). */
export let serverRestarting = false;
@@ -40,7 +89,15 @@ export function startEventsWS() {
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const data: unknown = JSON.parse(event.data);
// Validate the envelope before we dispatch — without this,
// a malformed/hostile server message becomes an arbitrary
// ``server:*`` CustomEvent on document, which feature
// listeners then trust.
if (!_isServerEventEnvelope(data)) {
logError('events-ws.message', `Discarded malformed server message`);
return;
}
if (data.type === 'server_restarting') {
serverRestarting = true;
showRestartingOverlay();
@@ -53,7 +110,9 @@ export function startEventsWS() {
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
};
ws.onerror = () => {};
ws.onerror = (err) => {
logError('events-ws.onerror', err);
};
}).catch(() => {
_ws = null;
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
+274 -39
View File
@@ -19,17 +19,42 @@
*/
import { desktopFocus } from './ui.ts';
import { escapeHtml } from './api.ts';
const POPUP_CLASS = 'icon-select-popup';
const FOCUSED_CLASS = 'focused';
const FOCUSED_SELECTOR = `.icon-select-cell.${FOCUSED_CLASS}`;
const CELL_SELECTOR = '.icon-select-cell';
const NAVIGABLE_SELECTOR = '.icon-select-cell:not(.disabled)';
/** Close every open icon-select popup. */
export function closeAllIconSelects() {
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => {
(p as HTMLElement).classList.remove('open');
});
/**
* Escape a value for use inside a double-quoted HTML attribute.
* `escapeHtml` (text-content escape) does not escape `"`, which leaves a
* stored-XSS vector when interpolating user-typed labels into attribute
* contexts like `data-value="${value}"`. This belt-and-braces helper
* covers ``& < > " '`` so the result is safe in any attribute slot.
*/
function escAttr(text: string | undefined | null): string {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Global click-away listener (registered once)
/** All registered IconSelect instances; lets `closeAllIconSelects` reach scroll-listener state. */
const _registry: Set<IconSelect> = new Set();
/** Close every open icon-select popup (and tear down their scroll listeners). */
export function closeAllIconSelects() {
for (const sel of _registry) {
sel._closeIfOpen();
}
}
// Global listeners (registered once)
let _globalListenerAdded = false;
function _ensureGlobalListener() {
if (_globalListenerAdded) return;
@@ -64,6 +89,79 @@ export interface IconSelectOpts {
searchPlaceholder?: string;
}
/**
* Move a keyboard cursor over a grid of cells.
*
* Returns the new focused index (clamped). Marks the chosen cell with the
* shared `.focused` class and scrolls it into view.
*/
function applyFocus(grid: HTMLElement, cells: HTMLElement[], idx: number): number {
grid.querySelectorAll(FOCUSED_SELECTOR).forEach(c => c.classList.remove(FOCUSED_CLASS));
if (cells.length === 0) return -1;
const clamped = Math.max(0, Math.min(idx, cells.length - 1));
cells[clamped].classList.add(FOCUSED_CLASS);
cells[clamped].scrollIntoView({ block: 'nearest', inline: 'nearest' });
return clamped;
}
/**
* Compute the column count of a CSS grid by comparing `offsetTop` of cells
* in the first row. Triggers a layout read callers should cache the result
* and invalidate only when the grid is rebuilt or filtered.
*/
function detectColumns(cells: HTMLElement[]): number {
if (cells.length === 0) return 1;
const firstTop = cells[0].offsetTop;
let cols = 0;
for (const c of cells) {
if (c.offsetTop !== firstTop) break;
cols++;
}
return Math.max(1, cols);
}
interface GridNavAction {
/** New focused index, or -1 to leave focus unchanged. */
nextIndex: number;
/** True when the key was consumed (caller should preventDefault). */
handled: boolean;
/** True when Enter was pressed and a cell should be picked. */
pick: boolean;
}
/**
* Pure keyboard-nav state machine shared by IconSelect and the standalone
* type-picker overlay. Returns what should happen; the caller decides
* preventDefault, stopPropagation, and cell-pick wiring.
*/
function handleGridKey(
key: string,
cur: number,
cellCount: number,
columns: number,
): GridNavAction {
if (cellCount === 0) return { nextIndex: -1, handled: false, pick: false };
const safe = cur >= 0 && cur < cellCount ? cur : 0;
switch (key) {
case 'ArrowRight':
return { nextIndex: Math.min(safe + 1, cellCount - 1), handled: true, pick: false };
case 'ArrowLeft':
return { nextIndex: Math.max(safe - 1, 0), handled: true, pick: false };
case 'ArrowDown':
return { nextIndex: Math.min(safe + columns, cellCount - 1), handled: true, pick: false };
case 'ArrowUp':
return { nextIndex: Math.max(safe - columns, 0), handled: true, pick: false };
case 'Home':
return { nextIndex: 0, handled: true, pick: false };
case 'End':
return { nextIndex: cellCount - 1, handled: true, pick: false };
case 'Enter':
return { nextIndex: safe, handled: true, pick: true };
default:
return { nextIndex: -1, handled: false, pick: false };
}
}
export class IconSelect {
_select: HTMLSelectElement;
_items: IconSelectItem[];
@@ -77,6 +175,7 @@ export class IconSelect {
_searchInput: HTMLInputElement | null = null;
_scrollHandler: (() => void) | null = null;
_scrollTargets: (HTMLElement | Window)[] = [];
_focusedIndex: number = -1;
constructor({ target, items, onChange, columns = 2, placeholder = '', searchable = false, searchPlaceholder = 'Filter…' }: IconSelectOpts) {
_ensureGlobalListener();
@@ -109,6 +208,8 @@ export class IconSelect {
this._trigger = document.createElement('button');
this._trigger.type = 'button';
this._trigger.className = 'icon-select-trigger';
this._trigger.setAttribute('aria-haspopup', 'listbox');
this._trigger.setAttribute('aria-expanded', 'false');
this._trigger.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
@@ -118,7 +219,10 @@ export class IconSelect {
// Build popup (portaled to body to avoid overflow clipping)
this._popup = document.createElement('div');
this._popup.className = POPUP_CLASS;
this._popup.tabIndex = -1;
this._popup.setAttribute('role', 'listbox');
this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.addEventListener('keydown', (e) => this._handleKeydown(e));
this._popup.innerHTML = this._buildGrid();
document.body.appendChild(this._popup);
@@ -126,15 +230,17 @@ export class IconSelect {
// Sync to current select value
this._syncTrigger();
_registry.add(this);
}
_bindPopupEvents() {
// Bind item clicks
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
cell.setAttribute('role', 'option');
cell.addEventListener('click', () => {
this.setValue((cell as HTMLElement).dataset.value!, true);
this._popup.classList.remove('open');
this._removeScrollListener();
this._closeIfOpen();
});
});
@@ -143,26 +249,68 @@ export class IconSelect {
if (this._searchInput) {
this._searchInput.addEventListener('input', () => {
const q = this._searchInput!.value.toLowerCase().trim();
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
const el = cell as HTMLElement;
el.classList.toggle('disabled', !!q && !el.dataset.search!.includes(q));
});
// Re-anchor keyboard cursor to first visible cell after filtering
this._setFocusedIndex(0);
});
}
}
/** Cells eligible for keyboard navigation (selectable + visible). */
_getNavigableCells(): HTMLElement[] {
return Array.from(this._popup.querySelectorAll<HTMLElement>(NAVIGABLE_SELECTOR));
}
/** Move the keyboard cursor to cell `idx` (clamped), scrolling it into view. */
_setFocusedIndex(idx: number) {
const cells = this._getNavigableCells();
this._focusedIndex = applyFocus(this._popup, cells, idx);
if (this._focusedIndex >= 0) {
const activeId = cells[this._focusedIndex].id || `icon-select-cell-${this._focusedIndex}`;
cells[this._focusedIndex].id = activeId;
this._popup.setAttribute('aria-activedescendant', activeId);
} else {
this._popup.removeAttribute('aria-activedescendant');
}
}
_handleKeydown(e: KeyboardEvent) {
if (!this._popup.classList.contains('open')) return;
const cells = this._getNavigableCells();
const action = handleGridKey(e.key, this._focusedIndex, cells.length, this._columns);
if (!action.handled) return;
e.preventDefault();
e.stopPropagation();
if (action.pick) {
const cell = cells[action.nextIndex];
if (!cell) return;
this.setValue(cell.dataset.value!, true);
this._closeIfOpen();
desktopFocus(this._trigger);
return;
}
this._setFocusedIndex(action.nextIndex);
}
_buildGrid() {
// item.icon is a raw SVG string by design (callers pass project-owned
// icon literals). label/desc/value are user-visible text and may
// originate from user input — escape them everywhere they cross
// an innerHTML boundary.
const cells = this._items.map(item => {
const search = (item.label + ' ' + (item.desc || '')).toLowerCase();
return `<div class="icon-select-cell" data-value="${item.value}" data-search="${search}">
return `<div class="icon-select-cell" data-value="${escAttr(item.value)}" data-search="${escAttr(search)}">
<span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
<span class="icon-select-cell-label">${escapeHtml(item.label)}</span>
${item.desc ? `<span class="icon-select-cell-desc">${escapeHtml(item.desc)}</span>` : ''}
</div>`;
}).join('');
const searchHTML = this._searchable
? `<input class="icon-select-search" type="text" placeholder="${this._searchPlaceholder}" autocomplete="off">`
? `<input class="icon-select-search" type="text" placeholder="${escAttr(this._searchPlaceholder)}" autocomplete="off">`
: '';
return searchHTML + `<div class="icon-select-grid" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
}
@@ -173,17 +321,19 @@ export class IconSelect {
if (item) {
this._trigger.innerHTML =
`<span class="icon-select-trigger-icon">${item.icon}</span>` +
`<span class="icon-select-trigger-label">${item.label}</span>` +
`<span class="icon-select-trigger-label">${escapeHtml(item.label)}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
} else if (this._placeholder) {
this._trigger.innerHTML =
`<span class="icon-select-trigger-label">${this._placeholder}</span>` +
`<span class="icon-select-trigger-label">${escapeHtml(this._placeholder)}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
}
// Update active state in grid
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
const el = cell as HTMLElement;
el.classList.toggle('active', el.dataset.value === val);
const active = el.dataset.value === val;
el.classList.toggle('active', active);
el.setAttribute('aria-selected', active ? 'true' : 'false');
});
}
@@ -225,23 +375,46 @@ export class IconSelect {
if (!wasOpen) {
this._positionPopup();
this._popup.classList.add('open');
this._trigger.setAttribute('aria-expanded', 'true');
this._addScrollListener();
if (this._searchInput) {
this._searchInput.value = '';
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
(cell as HTMLElement).classList.remove('disabled');
});
requestAnimationFrame(() => desktopFocus(this._searchInput!));
} else {
// No search input — focus the popup itself so it captures keydown
requestAnimationFrame(() => desktopFocus(this._popup));
}
// Seed keyboard cursor on the currently-selected cell (or first cell)
const cells = this._getNavigableCells();
const activeIdx = cells.findIndex(c => c.dataset.value === this._select.value);
this._setFocusedIndex(activeIdx >= 0 ? activeIdx : 0);
}
}
/** Close the popup if it is open and tear down listeners / focus state. */
_closeIfOpen() {
if (!this._popup.classList.contains('open')) return;
this._popup.classList.remove('open');
this._trigger.setAttribute('aria-expanded', 'false');
this._removeScrollListener();
this._clearFocusedCell();
}
_clearFocusedCell() {
this._popup.querySelectorAll(FOCUSED_SELECTOR)
.forEach(c => c.classList.remove(FOCUSED_CLASS));
this._focusedIndex = -1;
this._popup.removeAttribute('aria-activedescendant');
}
/** Close popup when any scrollable ancestor scrolls (prevents stale position). */
_addScrollListener() {
if (this._scrollHandler) return;
this._scrollHandler = () => {
this._popup.classList.remove('open');
this._removeScrollListener();
this._closeIfOpen();
};
// Listen on capture phase to catch scroll on any ancestor
let el: Node | null = this._trigger.parentNode;
@@ -289,6 +462,7 @@ export class IconSelect {
/** Remove the enhancement, restore native <select>. */
destroy() {
this._removeScrollListener();
_registry.delete(this);
this._trigger.remove();
this._popup.remove();
this._select.style.display = '';
@@ -317,11 +491,14 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
const showFilter = items.length > 9;
function buildCells(cellItems: IconSelectItem[]): string {
// item.icon is trusted raw SVG. label/desc/value are escaped at
// every innerHTML boundary because callers route user-typed text
// (device names, entity labels) through this picker.
return cellItems.map(item =>
`<div class="icon-select-cell" data-value="${item.value}" data-search="${(item.label + ' ' + (item.desc || '')).toLowerCase()}">
`<div class="icon-select-cell" data-value="${escAttr(item.value)}" data-search="${escAttr((item.label + ' ' + (item.desc || '')).toLowerCase())}" role="option">
<span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
<span class="icon-select-cell-label">${escapeHtml(item.label)}</span>
${item.desc ? `<span class="icon-select-cell-desc">${escapeHtml(item.desc)}</span>` : ''}
</div>`
).join('');
}
@@ -329,7 +506,7 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
// Build filter tabs HTML
const tabsHtml = filterTabs && filterTabs.length > 0
? `<div class="type-picker-tabs">${filterTabs.map((tab, i) =>
`<button class="type-picker-tab${i === 0 ? ' active' : ''}" data-filter-key="${tab.key}">${tab.label}</button>`
`<button class="type-picker-tab${i === 0 ? ' active' : ''}" data-filter-key="${escAttr(tab.key)}">${escapeHtml(tab.label)}</button>`
).join('')}</div>`
: '';
@@ -337,20 +514,46 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
const overlay = document.createElement('div');
overlay.className = 'type-picker-overlay';
overlay.innerHTML = `
<div class="type-picker-dialog">
<div class="type-picker-title">${title}</div>
<div class="type-picker-dialog" role="dialog" aria-modal="true" aria-label="${escAttr(title)}">
<div class="type-picker-title">${escapeHtml(title)}</div>
${tabsHtml}
${showFilter ? '<input class="type-picker-filter" type="text" placeholder="Filter…" autocomplete="off">' : ''}
<div class="icon-select-grid">${buildCells(items)}</div>
<div class="icon-select-grid" role="listbox">${buildCells(items)}</div>
</div>`;
document.body.appendChild(overlay);
const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); };
const grid = overlay.querySelector('.icon-select-grid') as HTMLElement;
const filterInput = (showFilter
? overlay.querySelector('.type-picker-filter') as HTMLInputElement
: null);
let focusedIdx = -1;
// Cache the column count; recomputed only when the grid is rebuilt or filtered.
let cachedColumns = 1;
const getNavCells = (): HTMLElement[] =>
Array.from(grid.querySelectorAll<HTMLElement>(NAVIGABLE_SELECTOR));
const refreshColumns = () => {
cachedColumns = detectColumns(getNavCells());
};
const setFocused = (idx: number) => {
const cells = getNavCells();
focusedIdx = applyFocus(grid, cells, idx);
if (focusedIdx >= 0) {
const id = cells[focusedIdx].id || `type-picker-cell-${focusedIdx}`;
cells[focusedIdx].id = id;
grid.setAttribute('aria-activedescendant', id);
} else {
grid.removeAttribute('aria-activedescendant');
}
};
function bindCellClicks() {
grid.querySelectorAll('.icon-select-cell').forEach(cell => {
grid.querySelectorAll(CELL_SELECTOR).forEach(cell => {
cell.addEventListener('click', () => {
if (cell.classList.contains('disabled')) return;
close();
@@ -370,22 +573,25 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
const newItems = onFilterChange(key);
grid.innerHTML = buildCells(newItems);
bindCellClicks();
refreshColumns();
setFocused(0);
});
});
}
// Filter logic
if (showFilter) {
const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement;
input.addEventListener('input', () => {
const q = input.value.toLowerCase().trim();
grid.querySelectorAll('.icon-select-cell').forEach(cell => {
if (filterInput) {
filterInput.addEventListener('input', () => {
const q = filterInput.value.toLowerCase().trim();
grid.querySelectorAll(CELL_SELECTOR).forEach(cell => {
const el = cell as HTMLElement;
const match = !q || el.dataset.search!.includes(q);
el.classList.toggle('disabled', !match);
});
refreshColumns();
setFocused(0);
});
requestAnimationFrame(() => setTimeout(() => desktopFocus(input), 200));
requestAnimationFrame(() => setTimeout(() => desktopFocus(filterInput), 200));
}
// Backdrop click
@@ -393,12 +599,41 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
if (e.target === overlay) close();
});
// Escape key
// Keyboard navigation
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
if (e.key === 'Escape') { close(); return; }
// Don't hijack arrow keys while the user is editing the filter
// input — let the caret move inside the text field normally.
if (filterInput && document.activeElement === filterInput
&& (e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|| e.key === 'Home' || e.key === 'End')) {
return;
}
const cells = getNavCells();
if (cells.length === 0) return;
const action = handleGridKey(e.key, focusedIdx, cells.length, cachedColumns);
if (!action.handled) return;
e.preventDefault();
if (action.pick) {
// Treat focusedIdx === -1 (rAF race before initial setFocused) as
// the first cell, matching the visual cursor seed.
const idx = action.nextIndex >= 0 ? action.nextIndex : 0;
const cell = cells[idx];
if (!cell) return;
close();
onPick(cell.dataset.value!);
return;
}
setFocused(action.nextIndex);
};
document.addEventListener('keydown', onKey);
// Animate in
requestAnimationFrame(() => overlay.classList.add('open'));
// Animate in, prime the column cache, and seed keyboard cursor on first cell.
requestAnimationFrame(() => {
overlay.classList.add('open');
refreshColumns();
setFocused(0);
});
}
+2 -1
View File
@@ -18,7 +18,7 @@ const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) };
const _colorStripTypeIcons = {
picture_advanced: _svg(P.monitor),
static: _svg(P.palette), gradient: _svg(P.rainbow),
single_color: _svg(P.palette), gradient: _svg(P.rainbow),
effect: _svg(P.zap), composite: _svg(P.link),
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
audio: _svg(P.music), audio_visualization: _svg(P.music),
@@ -42,6 +42,7 @@ const _valueSourceTypeIcons = {
css_extract: _svg(P.droplets),
system_metrics: _svg(P.cpu),
game_event: _svg(P.gamepad2),
http: _svg(P.globe),
};
const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.slidersHorizontal) };
const _deviceTypeIcons = {
@@ -0,0 +1,274 @@
/**
* MiniSelect compact, icon-less dropdown that replaces plain ``<select>``.
*
* IconSelect requires an SVG icon per option, which doesn't fit the
* dashboard's inline perf-cell controls (mode / window / yScale). Plain
* ``<select>`` is banned project-wide because it breaks the UI's visual
* consistency. MiniSelect fills the gap: a styled trigger button that
* shows the current option label, plus a small popup with the option
* labels. The original ``<select>`` is hidden but kept in the DOM and
* receives a native ``change`` event whenever the user picks an option,
* so existing handlers keep working with no changes.
*
* Usage:
* const sel = document.getElementById('perf-mode') as HTMLSelectElement;
* new MiniSelect(sel);
*
* The trigger displays each option's visible text from its ``<option>``
* label; option values come from the underlying ``<select>``.
*/
import { closeAllIconSelects } from './icon-select.ts';
import { escapeHtml } from './api.ts';
import { desktopFocus } from './ui.ts';
const POPUP_CLASS = 'mini-select-popup';
const FOCUSED_CLASS = 'focused';
const CELL_SELECTOR = '.mini-select-option';
const FOCUSED_SELECTOR = `${CELL_SELECTOR}.${FOCUSED_CLASS}`;
const _registry: Set<MiniSelect> = new Set();
/** Close every open MiniSelect popup. */
export function closeAllMiniSelects(): void {
for (const ms of _registry) ms._close();
}
let _globalListenerAdded = false;
function _ensureGlobalListener(): void {
if (_globalListenerAdded) return;
_globalListenerAdded = true;
document.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
if (!t.closest(`.${POPUP_CLASS}`) && !t.closest('.mini-select-trigger')) {
closeAllMiniSelects();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllMiniSelects();
});
}
interface MiniSelectOption {
value: string;
label: string;
}
export class MiniSelect {
_select: HTMLSelectElement;
_options: MiniSelectOption[];
_trigger: HTMLButtonElement;
_popup: HTMLDivElement;
_focusedIndex = -1;
constructor(target: HTMLSelectElement) {
// Picking up plain ``<select>``s in the same modal as IconSelects is
// intentional — we want the same click-away/Escape behaviour, so we
// also close any open IconSelect popup when ours opens.
_ensureGlobalListener();
this._select = target;
this._options = Array.from(target.options).map((opt) => ({
value: opt.value,
label: opt.textContent || opt.value,
}));
target.style.display = 'none';
this._trigger = document.createElement('button');
this._trigger.type = 'button';
this._trigger.className = 'mini-select-trigger';
this._trigger.setAttribute('aria-haspopup', 'listbox');
this._trigger.setAttribute('aria-expanded', 'false');
if (target.title) this._trigger.title = target.title;
this._trigger.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
target.parentNode!.insertBefore(this._trigger, target.nextSibling);
this._popup = document.createElement('div');
this._popup.className = POPUP_CLASS;
this._popup.tabIndex = -1;
this._popup.setAttribute('role', 'listbox');
this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.addEventListener('keydown', (e) => this._onKey(e));
this._popup.innerHTML = this._buildPopup();
document.body.appendChild(this._popup);
this._bindOptionClicks();
this._syncTrigger();
_registry.add(this);
}
/** Refresh trigger label + popup content after an external change. */
refresh(): void {
// Rebuild option list in case the underlying <select> changed.
this._options = Array.from(this._select.options).map((opt) => ({
value: opt.value,
label: opt.textContent || opt.value,
}));
this._popup.innerHTML = this._buildPopup();
this._bindOptionClicks();
this._syncTrigger();
}
/** Remove the enhancement and restore the native <select>. */
destroy(): void {
_registry.delete(this);
this._trigger.remove();
this._popup.remove();
this._select.style.display = '';
}
// ── Internals ─────────────────────────────────────────────────
_buildPopup(): string {
return this._options
.map(
(o, i) =>
`<div class="mini-select-option" role="option" data-value="${escapeHtml(o.value)}" data-index="${i}">${escapeHtml(o.label)}</div>`,
)
.join('');
}
_bindOptionClicks(): void {
this._popup.querySelectorAll<HTMLElement>(CELL_SELECTOR).forEach((cell) => {
cell.addEventListener('click', () => {
this._pick(cell.dataset.value || '');
});
});
}
_syncTrigger(): void {
const cur = this._options.find((o) => o.value === this._select.value);
const label = cur ? cur.label : this._options[0]?.label || '';
this._trigger.innerHTML = `<span class="mini-select-trigger-label">${escapeHtml(label)}</span><span class="mini-select-trigger-arrow">&#x25BE;</span>`;
this._popup.querySelectorAll<HTMLElement>(CELL_SELECTOR).forEach((cell) => {
const active = cell.dataset.value === this._select.value;
cell.classList.toggle('active', active);
cell.setAttribute('aria-selected', active ? 'true' : 'false');
});
}
_position(): void {
const rect = this._trigger.getBoundingClientRect();
const pad = 8;
const gap = 4;
const popupW = Math.max(rect.width, 120);
const spaceBelow = window.innerHeight - rect.bottom - gap - pad;
const spaceAbove = rect.top - gap - pad;
const openUp = spaceBelow < 160 && spaceAbove > spaceBelow;
const available = openUp ? spaceAbove : spaceBelow;
let left = rect.left;
if (left + popupW > window.innerWidth - pad) {
left = window.innerWidth - pad - popupW;
}
if (left < pad) left = pad;
this._popup.style.left = `${left}px`;
this._popup.style.width = `${popupW}px`;
this._popup.style.maxHeight = `${available}px`;
if (openUp) {
this._popup.style.top = '';
this._popup.style.bottom = `${window.innerHeight - rect.top + gap}px`;
} else {
this._popup.style.top = `${rect.bottom + gap}px`;
this._popup.style.bottom = '';
}
}
_toggle(): void {
const isOpen = this._popup.classList.contains('open');
closeAllIconSelects();
closeAllMiniSelects();
if (isOpen) return;
this._position();
this._popup.classList.add('open');
this._trigger.setAttribute('aria-expanded', 'true');
requestAnimationFrame(() => desktopFocus(this._popup));
const activeIdx = this._options.findIndex((o) => o.value === this._select.value);
this._setFocused(activeIdx >= 0 ? activeIdx : 0);
}
_close(): void {
if (!this._popup.classList.contains('open')) return;
this._popup.classList.remove('open');
this._trigger.setAttribute('aria-expanded', 'false');
this._clearFocused();
}
_clearFocused(): void {
this._popup.querySelectorAll(FOCUSED_SELECTOR).forEach((c) => c.classList.remove(FOCUSED_CLASS));
this._focusedIndex = -1;
}
_setFocused(idx: number): void {
const cells = Array.from(this._popup.querySelectorAll<HTMLElement>(CELL_SELECTOR));
if (cells.length === 0) return;
const clamped = Math.max(0, Math.min(idx, cells.length - 1));
this._clearFocused();
cells[clamped].classList.add(FOCUSED_CLASS);
cells[clamped].scrollIntoView({ block: 'nearest', inline: 'nearest' });
this._focusedIndex = clamped;
}
_onKey(e: KeyboardEvent): void {
if (!this._popup.classList.contains('open')) return;
const total = this._options.length;
const cur = this._focusedIndex >= 0 ? this._focusedIndex : 0;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this._setFocused(Math.min(cur + 1, total - 1));
break;
case 'ArrowUp':
e.preventDefault();
this._setFocused(Math.max(cur - 1, 0));
break;
case 'Home':
e.preventDefault();
this._setFocused(0);
break;
case 'End':
e.preventDefault();
this._setFocused(total - 1);
break;
case 'Enter':
e.preventDefault();
if (this._focusedIndex >= 0) {
this._pick(this._options[this._focusedIndex].value);
}
break;
}
}
_pick(value: string): void {
this._select.value = value;
this._syncTrigger();
// Dispatch the native event so existing handlers attached to the
// underlying <select> keep working without modification.
this._select.dispatchEvent(new Event('change', { bubbles: true }));
this._close();
desktopFocus(this._trigger);
}
}
/**
* Enhance every plain ``<select>`` matching *selector* under *root* into a
* MiniSelect. Selects that are already enhanced by another component
* (``IconSelect`` / ``EntitySelect`` both hide the target via
* ``style.display = 'none'``) are skipped so the two wrappers don't compete
* for the same `<select>`.
*/
export function enhanceMiniSelects(root: ParentNode, selector = 'select'): MiniSelect[] {
const out: MiniSelect[] = [];
root.querySelectorAll<HTMLSelectElement>(selector).forEach((sel) => {
if (sel.dataset.miniEnhanced === '1') return;
// Skip selects already hidden by an upstream IconSelect/EntitySelect.
if (sel.style.display === 'none') return;
sel.dataset.miniEnhanced = '1';
out.push(new MiniSelect(sel));
});
return out;
}
@@ -156,6 +156,24 @@ export class Modal {
return Object.keys(this._initialValues).some(k => this._initialValues[k] !== cur[k]);
}
/**
* No-op save guard for edit modals. When `entityId` is truthy (we are
* editing an existing entity), snapshot tracking is configured, and no
* tracked field has changed, force-close the modal silently and return
* `true` so the caller can early-return skipping the network request
* and the misleading "updated" toast.
*
* Returns `false` when the save flow must continue (create flow, no
* snapshot taken, or at least one tracked field changed).
*/
closeIfPristine(entityId: unknown): boolean {
if (!entityId) return false;
if (Object.keys(this._initialValues).length === 0) return false;
if (this.isDirty()) return false;
this.forceClose();
return true;
}
showError(msg: string) {
if (this.errorEl) {
this.errorEl.textContent = msg;
+9 -1
View File
@@ -16,7 +16,7 @@ import { DataCache } from './cache.ts';
import type {
Device, OutputTarget, ColorStripSource, PatternTemplate,
ValueSource, AudioSource, PictureSource, ScenePreset,
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, Asset, Automation, Display, FilterDef, EngineInfo,
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
GameIntegration, GameAdapterInfo,
@@ -371,6 +371,14 @@ export const mqttSourcesCache = new DataCache<MQTTSource[]>({
});
mqttSourcesCache.subscribe(v => { _cachedMQTTSources = v; });
export let _cachedHTTPEndpoints: HTTPEndpoint[] = [];
export const httpEndpointsCache = new DataCache<HTTPEndpoint[]>({
endpoint: '/http/endpoints',
extractData: json => json.endpoints || [],
});
httpEndpointsCache.subscribe(v => { _cachedHTTPEndpoints = v; });
export const assetsCache = new DataCache<Asset[]>({
endpoint: '/assets',
extractData: json => json.assets || [],
@@ -43,6 +43,23 @@ export function writeJson(key: string, value: unknown): void {
}
}
/**
* Parse a JSON string, returning ``fallback`` on any parse failure.
*
* Use for hot-path string-to-JSON conversions where a malformed payload
* (corrupt WebSocket frame, stale ``data-*`` attribute, hand-edited
* mapping in storage) would otherwise raise an uncaught exception.
*/
export function safeJsonParse<T = unknown>(raw: string | null | undefined, fallback: T): T {
if (raw == null || raw === '') return fallback;
try {
return JSON.parse(raw) as T;
} catch (err) {
logError('safeJsonParse', err);
return fallback;
}
}
// ── Common type guards ────────────────────────────────────────
export function isObject(v: unknown): v is Record<string, unknown> {
@@ -199,6 +199,8 @@ export async function saveAdvancedCalibration(): Promise<void> {
const cssId = _state.cssId;
if (!cssId) return;
if (_modal.closeIfPristine(cssId)) return;
if (_state.lines.length === 0) {
showToast(t('calibration.advanced.no_lines_warning') || 'Add at least one line', 'error');
return;
@@ -380,6 +380,8 @@ export async function showAssetEditor(editId: string): Promise<void> {
export async function saveAssetMetadata(): Promise<void> {
const id = (document.getElementById('asset-editor-id') as HTMLInputElement).value;
if (assetEditorModal.closeIfPristine(id)) return;
const name = (document.getElementById('asset-editor-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('asset-editor-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('asset-editor-error')!;
@@ -194,6 +194,8 @@ export async function editAudioProcessingTemplate(templateId: string) {
export async function saveAudioProcessingTemplate() {
const templateId = (document.getElementById('apt-id') as HTMLInputElement).value;
if (aptModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('apt-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('apt-description') as HTMLInputElement).value.trim();
@@ -153,6 +153,8 @@ export function onAudioSourceTypeChange() {
export async function saveAudioSource() {
const id = (document.getElementById('audio-source-id') as HTMLInputElement).value;
if (audioSourceModal.closeIfPristine(id)) return;
const name = (document.getElementById('audio-source-name') as HTMLInputElement).value.trim();
const sourceType = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
const description = (document.getElementById('audio-source-description') as HTMLInputElement).value.trim() || null;
@@ -5,6 +5,7 @@
import {
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
scenePresetsCache, _cachedHASources, haSourcesCache,
_cachedValueSources, valueSourcesCache,
getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts';
import { prefetchHAEntities } from './home-assistant-sources.ts';
@@ -26,6 +27,7 @@ import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'
import { getBaseOrigin } from './settings.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { enhanceMiniSelects } from '../core/mini-select.ts';
import { attachProcessPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
@@ -106,6 +108,12 @@ class AutomationEditorModal extends Modal {
onForceClose() {
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
// Tear down any per-rule portal widgets (http_poll EntitySelect /
// IconSelect) the rule rows attached. Walks every rule row in the
// modal, no-op for rows that didn't stash widgets.
document
.querySelectorAll<HTMLElement>('#automation-rules-list .rule-fields-container')
.forEach(c => _disposeHTTPPollWidgets(c));
}
snapshotValues() {
@@ -226,6 +234,7 @@ export async function loadAutomations() {
automationsCacheObj.fetch(),
scenePresetsCache.fetch(),
haSourcesCache.fetch(),
valueSourcesCache.fetch(),
]);
const sceneMap = new Map(scenes.map(s => [s.id, s]));
@@ -345,8 +354,67 @@ const RULE_CHIP_RENDERERS: Record<string, RuleChipBuilder> = {
title: tooltip,
};
},
http_poll: (c) => {
const vsId = c.value_source_id || '';
const vs = (_cachedValueSources || []).find(v => v.id === vsId);
const vsLabel = vs?.name || (vsId ? vsId : t('automations.rule.http_poll.no_source'));
const op = c.operator || 'equals';
const opGlyph = _httpOpGlyph(op);
const rhs = op === 'exists' ? '' : ` ${c.value ?? ''}`;
return {
icon: _icon(P.globe),
text: `${vsLabel} ${opGlyph}${rhs}`,
title: t('automations.rule.http_poll'),
};
},
};
type HTTPPollOp = 'equals' | 'not_equals' | 'contains' | 'regex' | 'gt' | 'lt' | 'exists';
const HTTP_OP_KEYS: HTTPPollOp[] = [
'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists',
];
const _HTTP_OP_GLYPHS: Record<HTTPPollOp, string> = {
equals: '=',
not_equals: '≠',
contains: '∈',
regex: '/.../',
gt: '>',
lt: '<',
exists: '?',
};
const _HTTP_OP_ICON_PATHS: Record<HTTPPollOp, string> = {
equals: P.check,
not_equals: P.circleOff,
contains: P.search,
regex: P.code,
gt: P.chevronUp,
lt: P.chevronDown,
exists: P.zap,
};
function _httpOpGlyph(op: string): string {
return _HTTP_OP_GLYPHS[op as HTTPPollOp] ?? '=';
}
function _httpOpIconPath(op: string): string {
return _HTTP_OP_ICON_PATHS[op as HTTPPollOp] ?? P.check;
}
/** Destroy any EntitySelect / IconSelect widgets that the http_poll rule
* branch attached to *container*. Safe to call when there are none
* it just clears the stash. Called both before re-rendering the rule's
* fields (rule-type change) and before removing the rule row entirely. */
function _disposeHTTPPollWidgets(container: HTMLElement): void {
const stash = (container as any)._httpPollWidgets;
if (!stash) return;
try { stash.vsEntitySelect?.destroy?.(); } catch { /* widget already gone */ }
try { stash.opIconSelect?.destroy?.(); } catch { /* widget already gone */ }
delete (container as any)._httpPollWidgets;
}
/** Render a chain-arrow separator span. `+` between AND-rules,
* the localised OR label between OR-rules, and `` for the
* rule-chain scene-activation transition. */
@@ -681,11 +749,12 @@ export function addAutomationRule() {
_autoGenerateAutomationName();
}
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', '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,
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
http_poll: P.globe,
};
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
@@ -777,7 +846,7 @@ function addAutomationRuleRow(rule: any) {
<select class="rule-type-select">
${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-rule" onclick="this.closest('.automation-rule-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
<button type="button" class="btn-remove-rule" title="Remove">${ICON_TRASH}</button>
</div>
<div class="rule-fields-container" style="display:none"></div>
`;
@@ -794,14 +863,29 @@ function addAutomationRuleRow(rule: any) {
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
const container = row.querySelector('.rule-fields-container') as HTMLElement;
// Remove button — dispose any widgets the rule body stashed (portal
// overlays would otherwise leak) before pulling the row from the DOM.
const removeBtn = row.querySelector('.btn-remove-rule') as HTMLButtonElement;
removeBtn.addEventListener('click', () => {
_disposeHTTPPollWidgets(container);
row.remove();
const autoGen = (window as any)._autoGenerateAutomationName;
if (typeof autoGen === 'function') autoGen();
});
// Attach IconSelect to the rule type dropdown
const ruleIconSelect = new IconSelect({
target: typeSelect,
items: _buildRuleTypeItems(),
columns: 4,
columns: 3,
} as any);
function renderFields(type: any, data: any) {
// Tear down any widgets the previous renderFields call attached
// (EntitySelect/IconSelect portal overlays to document.body, so
// a bare ``container.innerHTML = …`` leaves them in the registry).
_disposeHTTPPollWidgets(container);
if (type === 'startup') {
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
return;
@@ -880,6 +964,7 @@ function addAutomationRuleRow(rule: any) {
</select>
</div>
</div>`;
enhanceMiniSelects(container, 'select.rule-display-state');
return;
}
if (type === 'mqtt') {
@@ -905,6 +990,7 @@ function addAutomationRuleRow(rule: any) {
</select>
</div>
</div>`;
enhanceMiniSelects(container, 'select.rule-mqtt-match-mode');
return;
}
if (type === 'home_assistant') {
@@ -987,6 +1073,87 @@ function addAutomationRuleRow(rule: any) {
return;
}
if (type === 'http_poll') {
const vsId = data.value_source_id || '';
const operator = data.operator || 'equals';
const valueStr = data.value || '';
container.innerHTML = `
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.http_poll.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.http_poll.value_source')}</label>
<select class="rule-http-value-source">
<option value=""></option>
</select>
</div>
<div class="rule-field">
<label>${t('automations.rule.http_poll.operator')}</label>
<select class="rule-http-operator">
${HTTP_OP_KEYS.map(k => `<option value="${k}" ${operator === k ? 'selected' : ''}>${t('automations.rule.http_poll.operator.' + k)}</option>`).join('')}
</select>
</div>
<div class="rule-field rule-http-value-field">
<label>${t('automations.rule.http_poll.value')}</label>
<input type="text" class="rule-http-value" value="${escapeHtml(valueStr)}" placeholder="${escapeHtml(t('automations.rule.http_poll.value.placeholder'))}">
</div>
</div>`;
// Pull only HTTP value sources (source_type === 'http')
const httpVs = (_cachedValueSources || []).filter((v: any) => v.source_type === 'http');
// Wire EntitySelect for the value source picker
const vsSelect = container.querySelector('.rule-http-value-source') as HTMLSelectElement;
// Pre-populate the option so EntitySelect can sync display text.
vsSelect.innerHTML = `<option value="">—</option>` +
httpVs.map((v: any) => `<option value="${v.id}" ${v.id === vsId ? 'selected' : ''}>${escapeHtml(v.name)}</option>`).join('');
vsSelect.value = vsId || '';
const vsEntitySelect = new EntitySelect({
target: vsSelect,
getItems: () => (_cachedValueSources || [])
.filter((v: any) => v.source_type === 'http')
.map((v: any) => ({
value: v.id,
label: v.name,
icon: _icon(P.globe),
desc: v.json_path || t('automations.rule.http_poll.raw_body'),
})),
placeholder: t('palette.search'),
});
// Wire IconSelect for operator
const opSelect = container.querySelector('.rule-http-operator') as HTMLSelectElement;
const opItems = HTTP_OP_KEYS.map(k => ({
value: k,
icon: _icon(_httpOpIconPath(k)),
label: t('automations.rule.http_poll.operator.' + k),
desc: t('automations.rule.http_poll.operator.' + k + '.desc'),
}));
const opIconSelect = new IconSelect({
target: opSelect,
items: opItems,
columns: 3,
onChange: (newOp: string) => {
// Hide the value field when operator is 'exists'
const valField = container.querySelector('.rule-http-value-field') as HTMLElement;
if (valField) valField.style.display = newOp === 'exists' ? 'none' : '';
},
});
// Sync initial visibility based on the operator we just loaded.
const valField = container.querySelector('.rule-http-value-field') as HTMLElement;
if (valField) valField.style.display = operator === 'exists' ? 'none' : '';
// Stash both widgets so they can be destroyed when the row's
// rule type changes (renderFields re-entry) or the row is
// removed (button.btn-remove-rule onclick — calls
// _disposeHTTPPollWidgets via the row's data hook).
(container as any)._httpPollWidgets = {
vsEntitySelect,
opIconSelect,
};
return;
}
if (type === 'webhook') {
if (data.token) {
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
@@ -1102,6 +1269,18 @@ function getAutomationEditorRules() {
state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact',
});
} else if (ruleType === 'http_poll') {
const op = (row.querySelector('.rule-http-operator') as HTMLSelectElement).value || 'equals';
const r: any = {
rule_type: 'http_poll',
value_source_id: (row.querySelector('.rule-http-value-source') as HTMLSelectElement).value,
operator: op,
};
// The 'exists' operator has no comparison value.
if (op !== 'exists') {
r.value = (row.querySelector('.rule-http-value') as HTMLInputElement).value;
}
rules.push(r);
} else {
const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value;
const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim();
@@ -1114,6 +1293,8 @@ function getAutomationEditorRules() {
export async function saveAutomationEditor() {
const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
if (automationModal.closeIfPristine(idInput.value)) return;
const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
@@ -914,6 +914,8 @@ export async function saveCalibration() {
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement).value;
const error = document.getElementById('calibration-error') as HTMLElement;
if (calibModal.closeIfPristine(cssMode ? cssId : deviceId)) return;
if (cssMode) {
await _clearCSSTestMode();
} else {
@@ -38,7 +38,7 @@ registerIconEntityType('color_strip_source', makeSimpleIconAdapter<ColorStripSou
typeLabelKey: 'device.icon.entity.color_strip_source',
typeLabelFallback: 'Color strip',
cardSelectors: (id) => [`[data-css-id="${CSS.escape(id)}"]`],
bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'static' }),
bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'single_color' }),
}));
/* ── Types ────────────────────────────────────────────────────── */
@@ -88,7 +88,7 @@ function _gradientEntityStripHTML(stops: Array<{ position: number; color: number
/* ── Non-picture types set ────────────────────────────────────── */
const NON_PICTURE_TYPES = new Set([
'static', 'gradient', 'effect', 'composite', 'mapped',
'single_color', 'gradient', 'effect', 'composite', 'mapped',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'math_wave',
]);
@@ -96,8 +96,8 @@ const NON_PICTURE_TYPES = new Set([
/* ── Per-type card property renderers ─────────────────────────── */
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
static: (source, { clockBadge, animBadge }) => {
const colorBadge = _bindableColorBadge(source.color, [255, 255, 255], t('color_strip.static_color'));
single_color: (source, { clockBadge, animBadge }) => {
const colorBadge = _bindableColorBadge(source.color, [255, 255, 255], t('color_strip.single_color'));
return `
${colorBadge}
${animBadge}
@@ -312,7 +312,7 @@ function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Rec
/* ── Main card builder ────────────────────────────────────────── */
const STRIP_BADGE: Record<string, string> = {
static: 'STRIP · COLOR',
single_color: 'STRIP · COLOR',
gradient: 'STRIP · GRD',
effect: 'STRIP · FX',
composite: 'STRIP · COMP',
@@ -336,7 +336,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
? `<span class="stream-card-prop stream-card-link" title="${t('color_strip.clock')}" onclick="event.stopPropagation(); navigateToCard('streams','sync','sync-clocks','data-id','${source.clock_id}')">${ICON_CLOCK} ${escapeHtml(clockObj.name)}</span>`
: source.clock_id ? `<span class="stream-card-prop">${ICON_CLOCK} ${source.clock_id}</span>` : '';
const isAnimatable = source.source_type === 'static' || source.source_type === 'gradient';
const isAnimatable = source.source_type === 'single_color' || source.source_type === 'gradient';
const anim = isAnimatable && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
@@ -194,6 +194,8 @@ export async function closeGradientEditor() {
export async function saveGradientEntity() {
const id = (document.getElementById('gradient-editor-id') as HTMLInputElement).value;
if (gradientEditorModal.closeIfPristine(id)) return;
const name = (document.getElementById('gradient-editor-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('gradient-editor-description') as HTMLInputElement).value.trim() || null;
const tags = _gradientTagsInput ? _gradientTagsInput.getValue() : [];
@@ -127,7 +127,7 @@ class CSSEditorModal extends Modal {
if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; }
if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; }
if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = null; }
if (_staticColorWidget) { _staticColorWidget.destroy(); _staticColorWidget = null; }
if (_singleColorWidget) { _singleColorWidget.destroy(); _singleColorWidget = null; }
if (_effectColorWidget) { _effectColorWidget.destroy(); _effectColorWidget = null; }
if (_apiInputFallbackColorWidget) { _apiInputFallbackColorWidget.destroy(); _apiInputFallbackColorWidget = null; }
if (_candlelightColorWidget) { _candlelightColorWidget.destroy(); _candlelightColorWidget = null; }
@@ -150,7 +150,7 @@ class CSSEditorModal extends Modal {
picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3',
color: _staticColorWidget ? JSON.stringify(_staticColorWidget.getValue()) : '[]',
color: _singleColorWidget ? JSON.stringify(_singleColorWidget.getValue()) : '[]',
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
@@ -223,7 +223,7 @@ let _candlelightWindWidget: BindableScalarWidget | null = null;
let _weatherSpeedWidget: BindableScalarWidget | null = null;
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
let _staticColorWidget: BindableColorWidget | null = null;
let _singleColorWidget: BindableColorWidget | null = null;
let _effectColorWidget: BindableColorWidget | null = null;
let _apiInputFallbackColorWidget: BindableColorWidget | null = null;
let _candlelightColorWidget: BindableColorWidget | null = null;
@@ -303,7 +303,7 @@ async function configureKCRegions(sourceId: string): Promise<void> {
// ══════════════════════════════════════════════════════════════════
const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient',
'picture', 'picture_advanced', 'single_color', 'gradient',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'game_event', 'math_wave',
@@ -341,7 +341,7 @@ function _ensureCSSTypeIconSelect() {
const CSS_SECTION_MAP: Record<string, string> = {
'picture': 'css-editor-picture-section',
'picture_advanced': 'css-editor-picture-section',
'static': 'css-editor-static-section',
'single_color': 'css-editor-single-color-section',
'gradient': 'css-editor-gradient-section',
'effect': 'css-editor-effect-section',
'composite': 'css-editor-composite-section',
@@ -399,7 +399,7 @@ export function onCSSTypeChange() {
const animSection = document.getElementById('css-editor-animation-section') as HTMLElement;
const animTypeSelect = document.getElementById('css-editor-animation-type') as HTMLSelectElement;
if (type === 'static' || type === 'gradient') {
if (type === 'single_color' || type === 'gradient') {
animSection.style.display = '';
const opts = type === 'gradient'
? ['none','breathing','gradient_shift','wave','noise_perturb','hue_rotate','strobe','sparkle','pulse','candle','rainbow_fade']
@@ -417,7 +417,7 @@ export function onCSSTypeChange() {
(document.getElementById('css-editor-led-count-group') as HTMLElement).style.display =
hasLedCount.includes(type) ? '' : 'none';
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
const clockTypes = ['single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
if (clockTypes.includes(type)) _populateClockDropdown();
@@ -726,16 +726,16 @@ function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget {
return _weatherTempInfluenceWidget;
}
function _ensureStaticColorWidget(): BindableColorWidget {
if (!_staticColorWidget) {
_staticColorWidget = new BindableColorWidget({
function _ensureSingleColorWidget(): BindableColorWidget {
if (!_singleColorWidget) {
_singleColorWidget = new BindableColorWidget({
container: document.getElementById('css-editor-color-container')!,
default: [255, 255, 255],
valueSources: () => _cachedValueSources,
idPrefix: 'css-editor-color',
});
}
return _staticColorWidget;
return _singleColorWidget;
}
function _ensureEffectColorWidget(): BindableColorWidget {
@@ -988,17 +988,17 @@ function _autoGenerateCSSName() {
// ══════════════════════════════════════════════════════════════════
const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...args: any[]) => any; getPayload: (name: any) => any }> = {
static: {
single_color: {
load(css) {
_ensureStaticColorWidget().setValue(css.color);
_ensureSingleColorWidget().setValue(css.color);
_loadAnimationState(css.animation);
},
reset() {
_ensureStaticColorWidget().setValue([255, 255, 255]);
_ensureSingleColorWidget().setValue([255, 255, 255]);
_loadAnimationState(null);
},
getPayload(name) {
return { name, color: _ensureStaticColorWidget().getValue(), animation: _getAnimationPayload() };
return { name, color: _ensureSingleColorWidget().getValue(), animation: _getAnimationPayload() };
},
},
gradient: {
@@ -1417,7 +1417,7 @@ export function getCSSEditorPreviewPayload(sourceType: string): any {
const payload = handler.getPayload('__preview__');
if (!payload) return null;
payload.source_type = sourceType;
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
const clockTypes = ['single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
if (clockTypes.includes(sourceType)) {
const clockEl = document.getElementById('css-editor-clock') as HTMLInputElement | null;
if (clockEl && clockEl.value) payload.clock_id = clockEl.value;
@@ -1559,6 +1559,8 @@ export function isCSSEditorDirty() { return cssEditorModal.isDirty(); }
export async function saveCSSEditor() {
const cssId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
if (cssEditorModal.closeIfPristine(cssId)) return;
const name = (document.getElementById('css-editor-name') as HTMLInputElement).value.trim();
const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
@@ -1571,7 +1573,7 @@ export async function saveCSSEditor() {
payload.source_type = knownType ? sourceType : 'picture';
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
const clockTypes = ['single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
if (clockTypes.includes(sourceType)) {
payload.clock_id = (document.getElementById('css-editor-clock') as HTMLInputElement).value || null;
}
@@ -30,7 +30,7 @@ import { openAuthedWs } from '../../core/ws-auth.ts';
/* ── Preview config builder ───────────────────────────────────── */
const _PREVIEW_TYPES = new Set([
'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification', 'audio', 'math_wave',
'single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'notification', 'audio', 'math_wave',
'weather', 'game_event', 'api_input', 'mapped', 'composite', 'processed',
]);
@@ -38,8 +38,8 @@ function _collectPreviewConfig() {
const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
if (!_PREVIEW_TYPES.has(sourceType)) return null;
let config: any;
if (sourceType === 'static') {
config = { source_type: 'static', color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), animation: _getAnimationPayload() };
if (sourceType === 'single_color') {
config = { source_type: 'single_color', color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), animation: _getAnimationPayload() };
} else if (sourceType === 'gradient') {
const stops = getGradientStops();
if (stops.length < 2) return null;
@@ -45,6 +45,7 @@ import {
import {
ICON_X, ICON_EYE, ICON_EYE_OFF, ICON_DOWNLOAD, ICON_REFRESH,
} from '../core/icons.ts';
import { enhanceMiniSelects } from '../core/mini-select.ts';
const ICON_DRAG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>';
const ICON_LOCK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
@@ -173,6 +174,10 @@ function _renderPanelBody(): void {
${_renderActions()}
`;
_bindHandlers(body);
// Enhance the compact <select> elements rendered above into MiniSelect
// popups. The native elements stay in the DOM hidden, so the change
// handlers attached in _bindHandlers continue to fire on selection.
enhanceMiniSelects(body, 'select.dash-cust-mini-select');
if (_focusAfterRender) {
const el = body.querySelector<HTMLElement>(_focusAfterRender);
el?.focus();
@@ -189,9 +194,9 @@ function _renderPresets(layout: DashboardLayoutV1): string {
${t('dashboard.customize.preset.' + name)}
</button>`;
}).join('');
const modifiedHint = layout.presetActive
? ''
: `<span class="dash-cust-modified">${t('dashboard.customize.modified')}</span>`;
const modifiedHint = layout.userModified
? `<span class="dash-cust-modified">${t('dashboard.customize.modified')}</span>`
: '';
return `<section class="dash-cust-section">
<h3 class="dash-cust-h3">${t('dashboard.customize.presets')}${modifiedHint}</h3>
<div class="dash-cust-chips">${chips}</div>
@@ -110,8 +110,18 @@ export interface DashboardLayoutV1 {
perfCells: PerfCellConfig[];
global: GlobalConfig;
/** Active preset key when the layout matches a built-in unmodified.
* Cleared on any user edit so the panel can show "modified" state. */
* Drives the chip highlight in the customize panel recomputed on
* every save/load so a user who toggles back to a preset's exact
* shape sees the chip light up again. */
presetActive?: string;
/** True iff the user has made changes via the customize panel since
* the last preset apply / reset / import. Drives the "MODIFIED"
* indicator. Kept separate from `presetActive` so that legacy
* layouts whose data drifted from every preset (older app versions
* saved with different defaults, prior buggy migrations) don't
* flash a perpetual "MODIFIED" warning at users who never actually
* customized anything. */
userModified?: boolean;
}
const _defaultSection = (key: string, visible = true): SectionConfig => ({
@@ -226,6 +236,7 @@ function _clone(layout: DashboardLayoutV1, presetActive?: string): DashboardLayo
perfCells: layout.perfCells.map(c => ({ ...c })),
global: { ...layout.global },
presetActive,
userModified: layout.userModified,
};
}
@@ -429,16 +440,22 @@ async function _pushToServer(layout: DashboardLayoutV1): Promise<void> {
}
}
/** Apply a built-in preset and persist it. */
/** Apply a built-in preset and persist it. Clears the `userModified`
* flag applying a preset is the user's signal that they want to
* start from a clean baseline. */
export function applyDashboardPreset(name: string): void {
const factory = PRESETS[name];
if (!factory) return;
saveDashboardLayout(factory());
const next = factory();
next.userModified = false;
saveDashboardLayout(next);
}
/** Reset to the studio default. */
/** Reset to the studio default. Clears `userModified`. */
export function resetDashboardLayout(): void {
saveDashboardLayout(PRESETS.studio());
const next = PRESETS.studio();
next.userModified = false;
saveDashboardLayout(next);
}
/** Export the current layout as a downloadable JSON string. */
@@ -453,6 +470,7 @@ export function importDashboardLayoutJson(json: string): boolean {
if (!parsed || typeof parsed !== 'object') return false;
const merged = _mergeWithDefaults(parsed);
merged.presetActive = undefined;
merged.userModified = true;
saveDashboardLayout(merged);
return true;
} catch (e) {
@@ -514,6 +532,7 @@ export function setSectionVisible(layout: DashboardLayoutV1, key: string, visibl
const s = next.sections.find(s => s.key === key);
if (s) s.visible = visible;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -529,6 +548,7 @@ export function setSectionOrder(layout: DashboardLayoutV1, orderedKeys: string[]
for (const s of map.values()) reordered.push(s);
next.sections = reordered;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -537,6 +557,7 @@ export function setSectionDensity(layout: DashboardLayoutV1, key: string, densit
const s = next.sections.find(s => s.key === key);
if (s) s.density = density;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -545,6 +566,7 @@ export function setSectionCollapsedDefault(layout: DashboardLayoutV1, key: strin
const s = next.sections.find(s => s.key === key);
if (s) s.collapsedDefault = collapsed;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -553,6 +575,7 @@ export function setPerfCellVisible(layout: DashboardLayoutV1, key: string, visib
const c = next.perfCells.find(c => c.key === key);
if (c) c.visible = visible;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -567,6 +590,7 @@ export function setPerfCellOrder(layout: DashboardLayoutV1, orderedKeys: string[
for (const c of map.values()) reordered.push(c);
next.perfCells = reordered;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -575,6 +599,7 @@ export function setPerfCellMode(layout: DashboardLayoutV1, key: string, mode: Pe
const c = next.perfCells.find(c => c.key === key);
if (c) c.mode = mode;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -583,6 +608,7 @@ export function setPerfCellWindow(layout: DashboardLayoutV1, key: string, window
const c = next.perfCells.find(c => c.key === key);
if (c) c.window = window;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -590,6 +616,7 @@ export function setGlobalPerfWindow(layout: DashboardLayoutV1, window: SampleWin
const next = _clone(layout);
next.global.perfWindow = window;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -598,6 +625,7 @@ export function setPerfCellYScale(layout: DashboardLayoutV1, key: string, yScale
const c = next.perfCells.find(c => c.key === key);
if (c) c.yScale = yScale;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -605,6 +633,7 @@ export function setGlobalPerfMode(layout: DashboardLayoutV1, mode: 'system' | 'a
const next = _clone(layout);
next.global.perfMode = mode;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -612,53 +641,97 @@ export function setGlobalConfig(layout: DashboardLayoutV1, patch: Partial<Global
const next = _clone(layout);
next.global = { ...next.global, ...patch };
next.presetActive = undefined;
next.userModified = true;
return next;
}
// ── Internal: merge / migrate ────────────────────────────────────────────
/** Merge a saved keyed array (sections, perfCells) with the canonical
* defaults. Unknown keys in `saved` are dropped; missing keys are filled
* from defaults.
*
* Ordering: when the saved keys form a *subsequence* of the canonical
* order i.e. the user never manually reordered, they just happen to
* have an older snapshot missing newly-added registry entries the
* merged result uses the canonical order. Without this, every new
* default key added in a later release would land at the end of the
* user's saved list, drifting the layout off every preset and pinning
* the customize panel in "MODIFIED" forever after an upgrade.
*
* When the user *did* reorder (saved order is not a subsequence of
* default), we preserve their order and append any new default keys at
* the end those entries are new to the user, so any position is a
* guess; end-of-list is at least predictable. */
function _mergeKeyedArray<T extends { key: string }>(
defaults: T[],
saved: T[],
mergeItem: (def: T, s: T) => T,
): T[] {
const defByKey = new Map(defaults.map(d => [d.key, d]));
const savedKnown = saved.filter(s => defByKey.has(s.key));
const savedByKey = new Map(savedKnown.map(s => [s.key, s]));
const defaultKeys = defaults.map(d => d.key);
const savedKeys = savedKnown.map(s => s.key);
let i = 0;
for (const dk of defaultKeys) {
if (i < savedKeys.length && dk === savedKeys[i]) i++;
}
const userReordered = i !== savedKeys.length;
const finalKeys = userReordered
? [...savedKeys, ...defaultKeys.filter(k => !savedByKey.has(k))]
: defaultKeys;
return finalKeys.map(k => {
const def = defByKey.get(k)!;
const s = savedByKey.get(k);
return s ? mergeItem(def, s) : { ...def };
});
}
/** Merge a (possibly partial or older) layout with current defaults. New
* registry keys not in the saved layout are appended to the end with
* default settings; unknown keys in the saved layout are dropped. */
* registry keys not in the saved layout are inserted at their canonical
* positions when the saved order is consistent with defaults; otherwise
* appended at the end (see `_mergeKeyedArray`). Unknown keys in the
* saved layout are dropped. */
function _mergeWithDefaults(input: unknown): DashboardLayoutV1 {
const base = _clone(DEFAULT_LAYOUT);
if (!input || typeof input !== 'object') return base;
const obj = input as Partial<DashboardLayoutV1>;
if (Array.isArray(obj.sections)) {
const known = new Map(base.sections.map(s => [s.key, s]));
const reordered: SectionConfig[] = [];
for (const s of obj.sections as SectionConfig[]) {
const def = known.get(s.key);
if (!def) continue;
reordered.push({
base.sections = _mergeKeyedArray(
base.sections,
obj.sections as SectionConfig[],
(def, s) => ({
...def,
...s,
options: { ...def.options, ...(s.options || {}) },
});
known.delete(s.key);
}
for (const s of known.values()) reordered.push(s);
base.sections = reordered;
}),
);
}
if (Array.isArray(obj.perfCells)) {
const known = new Map(base.perfCells.map(c => [c.key, c]));
const reordered: PerfCellConfig[] = [];
for (const c of obj.perfCells as PerfCellConfig[]) {
const def = known.get(c.key);
if (!def) continue;
reordered.push({ ...def, ...c });
known.delete(c.key);
}
for (const c of known.values()) reordered.push(c);
base.perfCells = reordered;
base.perfCells = _mergeKeyedArray(
base.perfCells,
obj.perfCells as PerfCellConfig[],
(def, c) => ({ ...def, ...c }),
);
}
if (obj.global && typeof obj.global === 'object') {
base.global = { ...base.global, ...obj.global };
}
// Legacy saves (before `userModified` existed) omit the field. Treat
// them as un-modified so existing users with naturally-drifted layouts
// don't see a perpetual MODIFIED hint. New mutations through the panel
// will set the flag and surface MODIFIED correctly going forward.
base.userModified = typeof obj.userModified === 'boolean'
? obj.userModified
: false;
base.presetActive = _computeActivePreset(base);
return base;
}
@@ -843,6 +843,8 @@ export function closeDeviceSettingsModal() { settingsModal.close(); }
export async function saveDeviceSettings() {
const deviceId = (document.getElementById('settings-device-id') as HTMLInputElement).value;
if (settingsModal.closeIfPristine(deviceId)) return;
const name = (document.getElementById('settings-device-name') as HTMLInputElement).value.trim();
const url = settingsModal._getUrl();
@@ -707,6 +707,8 @@ export async function showGameIntegrationEditor(editId: string | null = null) {
export async function saveGameIntegration() {
const id = (document.getElementById('gi-id') as HTMLInputElement).value;
if (giModal.closeIfPristine(id)) return;
const name = (document.getElementById('gi-name') as HTMLInputElement).value.trim();
if (!name) { giModal.showError(t('game_integration.error.name_required')); return; }
@@ -487,6 +487,8 @@ export async function closeHALightEditor(): Promise<void> {
export async function saveHALightEditor(): Promise<void> {
const targetId = (document.getElementById('ha-light-editor-id') as HTMLInputElement).value;
if (haLightEditorModal.closeIfPristine(targetId)) return;
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim();
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
const colorSourceRaw = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
@@ -142,6 +142,8 @@ export async function closeHASourceModal(): Promise<void> {
export async function saveHASource(): Promise<void> {
const id = (document.getElementById('ha-source-id') as HTMLInputElement).value;
if (haSourceModal.closeIfPristine(id)) return;
const name = (document.getElementById('ha-source-name') as HTMLInputElement).value.trim();
const host = (document.getElementById('ha-source-host') as HTMLInputElement).value.trim();
const token = (document.getElementById('ha-source-token') as HTMLInputElement).value.trim();
@@ -0,0 +1,618 @@
/**
* HTTP Endpoints CRUD, test, cards.
*
* An HTTP endpoint is a connection definition only (URL + auth +
* headers + timeout). Polling cadence is owned by HTTPValueSource
* one endpoint can back many value sources at different intervals.
*
* Structurally mirrors `home-assistant-sources.ts` (HA-source CRUD UI):
* register icon adapter modal subclass with dirty-check save/edit/
* clone/delete handlers card builder event delegation.
*/
import {
_cachedHTTPEndpoints, httpEndpointsCache,
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import {
ICON_EDIT, ICON_TRASH, ICON_EYE, ICON_EYE_OFF,
ICON_TEST, ICON_OK, ICON_WARNING,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import { IconSelect } from '../core/icon-select.ts';
import type { HTTPEndpoint, HTTPEndpointWritePayload, HTTPMethod, HTTPTestResponse } from '../types.ts';
registerIconEntityType('http_endpoint', makeSimpleIconAdapter<HTTPEndpoint>({
cache: httpEndpointsCache,
endpointPrefix: '/http/endpoints',
reload: async () => {
if (typeof (window as any).loadIntegrations === 'function') {
await (window as any).loadIntegrations();
}
},
typeLabelKey: 'device.icon.entity.http_endpoint',
typeLabelFallback: 'HTTP endpoint',
cardSelectors: (id) => [
`[data-card-section="http-endpoints"] [data-id="${CSS.escape(id)}"]`,
],
}));
const ICON_HTTP = `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`;
// ── Modal ─────────────────────────────────────────────────────
let _httpTagsInput: TagInput | null = null;
let _httpMethodIconSelect: IconSelect | null = null;
/** In-memory headers; mirrored into hidden snapshot field. */
let _httpHeaders: Array<{ name: string; value: string }> = [];
class HTTPEndpointModal extends Modal {
constructor() { super('http-endpoint-modal'); }
onForceClose() {
if (_httpTagsInput) { _httpTagsInput.destroy(); _httpTagsInput = null; }
if (_httpMethodIconSelect) { _httpMethodIconSelect.destroy(); _httpMethodIconSelect = null; }
_httpHeaders = [];
const out = document.getElementById('http-endpoint-test-output');
if (out) { out.innerHTML = ''; out.style.display = 'none'; }
}
snapshotValues() {
// Headers are compared as a sorted snapshot so the dirty-check is
// immune to a backend serialization that emits keys in a different
// order than the user originally entered them (false-positive
// "discard changes?" on reopen of multi-header endpoints).
const headersSnapshot = [..._httpHeaders]
.sort((a, b) => a.name.localeCompare(b.name));
return {
name: (document.getElementById('http-endpoint-name') as HTMLInputElement).value,
url: (document.getElementById('http-endpoint-url') as HTMLInputElement).value,
method: (document.getElementById('http-endpoint-method') as HTMLSelectElement).value,
auth_token: (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value,
timeout_s: (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value,
description: (document.getElementById('http-endpoint-description') as HTMLInputElement).value,
headers: JSON.stringify(headersSnapshot),
tags: JSON.stringify(_httpTagsInput ? _httpTagsInput.getValue() : []),
};
}
}
const httpEndpointModal = new HTTPEndpointModal();
// ── Method IconSelect (GET / HEAD) ────────────────────────────
function _ensureMethodIconSelect() {
const sel = document.getElementById('http-endpoint-method') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{
value: 'GET',
icon: `<svg class="icon" viewBox="0 0 24 24">${P.download}</svg>`,
label: 'GET',
desc: t('http_endpoint.method.get.desc'),
},
{
value: 'HEAD',
icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`,
label: 'HEAD',
desc: t('http_endpoint.method.head.desc'),
},
];
if (_httpMethodIconSelect) { _httpMethodIconSelect.updateItems(items); return; }
_httpMethodIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
// ── Headers list (key/value rows) ─────────────────────────────
// Visual model mirrors `.group-child-row` (cards.css): bordered rows on
// `--bg-color` with a subtle hover ring, inputs share the same height
// and rounded corners, trash button on the right. Empty state matches
// the `.pp-filter-empty` dashed-border pattern.
function _renderHeaderRows() {
const list = document.getElementById('http-endpoint-headers-list');
if (!list) return;
if (_httpHeaders.length === 0) {
list.innerHTML = `<div class="http-headers-empty">${escapeHtml(t('http_endpoint.headers.empty'))}</div>`;
return;
}
list.innerHTML = _httpHeaders.map((h, idx) => `
<div class="http-header-row" data-idx="${idx}">
<span class="http-header-index" aria-hidden="true">${idx + 1}</span>
<div class="http-header-fields">
<input type="text" class="http-header-name" placeholder="${escapeHtml(t('http_endpoint.headers.name_placeholder'))}" value="${escapeHtml(h.name)}" spellcheck="false" autocomplete="off">
<input type="text" class="http-header-value" placeholder="${escapeHtml(t('http_endpoint.headers.value_placeholder'))}" value="${escapeHtml(h.value)}" spellcheck="false" autocomplete="off">
</div>
<button type="button" class="btn btn-icon btn-secondary http-header-remove" title="${escapeHtml(t('common.delete'))}" aria-label="${escapeHtml(t('common.delete'))}">${ICON_TRASH}</button>
</div>
`).join('');
list.querySelectorAll<HTMLInputElement>('.http-header-name').forEach((inp) => {
inp.addEventListener('input', () => {
const row = inp.closest<HTMLElement>('.http-header-row')!;
const idx = parseInt(row.dataset.idx || '0', 10);
if (_httpHeaders[idx]) _httpHeaders[idx].name = inp.value;
});
});
list.querySelectorAll<HTMLInputElement>('.http-header-value').forEach((inp) => {
inp.addEventListener('input', () => {
const row = inp.closest<HTMLElement>('.http-header-row')!;
const idx = parseInt(row.dataset.idx || '0', 10);
if (_httpHeaders[idx]) _httpHeaders[idx].value = inp.value;
});
});
list.querySelectorAll<HTMLButtonElement>('.http-header-remove').forEach((btn) => {
btn.addEventListener('click', () => {
const row = btn.closest<HTMLElement>('.http-header-row')!;
const idx = parseInt(row.dataset.idx || '0', 10);
_httpHeaders.splice(idx, 1);
_renderHeaderRows();
});
});
}
export function addHTTPEndpointHeader() {
_httpHeaders.push({ name: '', value: '' });
_renderHeaderRows();
// Focus the newest row so the user can immediately start typing.
const list = document.getElementById('http-endpoint-headers-list');
if (list) {
const names = list.querySelectorAll<HTMLInputElement>('.http-header-name');
names[names.length - 1]?.focus();
}
}
// ── Show / Close ──────────────────────────────────────────────
export async function showHTTPEndpointModal(editData: HTTPEndpoint | null = null): Promise<void> {
const isEdit = !!editData;
const titleKey = isEdit ? 'http_endpoint.edit' : 'http_endpoint.add';
document.getElementById('http-endpoint-modal-title')!.innerHTML = `${ICON_HTTP} ${t(titleKey)}`;
(document.getElementById('http-endpoint-id') as HTMLInputElement).value = editData?.id || '';
(document.getElementById('http-endpoint-error') as HTMLElement).style.display = 'none';
const nameInput = document.getElementById('http-endpoint-name') as HTMLInputElement;
const urlInput = document.getElementById('http-endpoint-url') as HTMLInputElement;
const methodSel = document.getElementById('http-endpoint-method') as HTMLSelectElement;
const tokenInput = document.getElementById('http-endpoint-auth-token') as HTMLInputElement;
const timeoutInput = document.getElementById('http-endpoint-timeout') as HTMLInputElement;
const descInput = document.getElementById('http-endpoint-description') as HTMLInputElement;
nameInput.value = editData?.name || '';
urlInput.value = editData?.url || '';
methodSel.value = editData?.method || 'GET';
tokenInput.value = ''; // never expose stored token
tokenInput.type = 'password';
timeoutInput.value = String(editData?.timeout_s ?? 10);
descInput.value = editData?.description || '';
// Headers: snapshot from data into our editable buffer
_httpHeaders = editData?.headers
? Object.entries(editData.headers).map(([name, value]) => ({ name, value }))
: [];
_renderHeaderRows();
_ensureMethodIconSelect();
if (_httpMethodIconSelect) _httpMethodIconSelect.setValue(editData?.method || 'GET');
// Show "leave blank to keep" hint only when editing an endpoint
// that already has a token configured.
const tokenHint = document.getElementById('http-endpoint-token-hint');
if (tokenHint) tokenHint.style.display = (isEdit && editData?.auth_token_set) ? '' : 'none';
// Reveal password toggle
const revealBtn = document.getElementById('http-endpoint-token-reveal');
if (revealBtn) revealBtn.innerHTML = ICON_EYE;
// Inject icon into the inline Test button
const testBtnIcon = document.querySelector('#http-endpoint-test-btn .http-endpoint-test-btn-icon');
if (testBtnIcon) testBtnIcon.innerHTML = ICON_TEST;
// Reset test output
const out = document.getElementById('http-endpoint-test-output');
if (out) { out.innerHTML = ''; out.style.display = 'none'; }
// Tags
if (_httpTagsInput) { _httpTagsInput.destroy(); _httpTagsInput = null; }
_httpTagsInput = new TagInput(
document.getElementById('http-endpoint-tags-container'),
{ placeholder: t('tags.placeholder') }
);
_httpTagsInput.setValue(isEdit ? (editData?.tags || []) : []);
httpEndpointModal.open();
httpEndpointModal.snapshot();
}
export async function closeHTTPEndpointModal(): Promise<void> {
await httpEndpointModal.close();
}
export function toggleHTTPEndpointTokenVisibility() {
const inp = document.getElementById('http-endpoint-auth-token') as HTMLInputElement;
const btn = document.getElementById('http-endpoint-token-reveal');
if (!inp || !btn) return;
if (inp.type === 'password') {
inp.type = 'text';
btn.innerHTML = ICON_EYE_OFF;
} else {
inp.type = 'password';
btn.innerHTML = ICON_EYE;
}
}
// ── Header collection (commits live <input> values to buffer) ─
function _collectHeaders(): Record<string, string> {
// Pull current input values to be safe (in case of paste / IME).
const list = document.getElementById('http-endpoint-headers-list');
if (list) {
list.querySelectorAll<HTMLElement>('.http-header-row').forEach((row) => {
const idx = parseInt(row.dataset.idx || '0', 10);
const name = (row.querySelector('.http-header-name') as HTMLInputElement)?.value || '';
const value = (row.querySelector('.http-header-value') as HTMLInputElement)?.value || '';
if (_httpHeaders[idx]) { _httpHeaders[idx].name = name; _httpHeaders[idx].value = value; }
});
}
const out: Record<string, string> = {};
for (const h of _httpHeaders) {
const name = h.name.trim();
if (!name) continue;
out[name] = h.value;
}
return out;
}
// ── Save ──────────────────────────────────────────────────────
export async function saveHTTPEndpoint(): Promise<void> {
const id = (document.getElementById('http-endpoint-id') as HTMLInputElement).value;
if (httpEndpointModal.closeIfPristine(id)) return;
const name = (document.getElementById('http-endpoint-name') as HTMLInputElement).value.trim();
const url = (document.getElementById('http-endpoint-url') as HTMLInputElement).value.trim();
const method = (document.getElementById('http-endpoint-method') as HTMLSelectElement).value as HTTPMethod;
const token = (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value;
const timeoutRaw = (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value;
const description = (document.getElementById('http-endpoint-description') as HTMLInputElement).value.trim() || undefined;
const headers = _collectHeaders();
const timeout_s = parseFloat(timeoutRaw);
if (!name) {
httpEndpointModal.showError(t('http_endpoint.error.name_required'));
return;
}
if (!url) {
httpEndpointModal.showError(t('http_endpoint.error.url_required'));
return;
}
if (isNaN(timeout_s) || timeout_s <= 0) {
httpEndpointModal.showError(t('http_endpoint.error.timeout_invalid'));
return;
}
const payload: HTTPEndpointWritePayload = {
name, url, method, headers, timeout_s, description,
tags: _httpTagsInput ? _httpTagsInput.getValue() : [],
};
// Auth token semantics (per backend schema):
// POST — empty string means "no token"
// PUT new — non-empty replaces; empty string CLEARS; omit to KEEP existing.
// For PUT we omit the field unless the user typed something so we don't
// accidentally clear a previously-configured token.
if (!id) {
payload.auth_token = token;
} else if (token) {
payload.auth_token = token;
}
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/http/endpoints/${id}` : '/http/endpoints';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success');
httpEndpointModal.forceClose();
httpEndpointsCache.invalidate();
if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations();
} catch (e: any) {
if (e.isAuth) return;
httpEndpointModal.showError(e.message);
}
}
// ── Edit / Clone / Delete ─────────────────────────────────────
export async function editHTTPEndpoint(endpointId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
const data: HTTPEndpoint = await resp.json();
await showHTTPEndpointModal(data);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function cloneHTTPEndpoint(endpointId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
// Cloning never reveals the token — user must re-enter if needed.
data.auth_token_set = false;
await showHTTPEndpointModal(data);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function deleteHTTPEndpoint(endpointId: string): Promise<void> {
const confirmed = await showConfirm(t('http_endpoint.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('http_endpoint.deleted'), 'success');
httpEndpointsCache.invalidate();
if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Test (in-form, pre-save) ──────────────────────────────────
/** Builds a `HTTPTestRequest` from the live form values and renders
* the response inline. Works for both new and edit modes. */
export async function testHTTPEndpoint(): Promise<void> {
const url = (document.getElementById('http-endpoint-url') as HTMLInputElement).value.trim();
const method = (document.getElementById('http-endpoint-method') as HTMLSelectElement).value as HTTPMethod;
const token = (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value;
const timeoutRaw = (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value;
const headers = _collectHeaders();
const timeout_s = parseFloat(timeoutRaw);
const out = document.getElementById('http-endpoint-test-output');
if (!out) return;
// Render validation errors inline next to the Test button — the
// modal-level banner at the top of the form is invisible from here.
const renderValidationFail = (msg: string): void => {
out.style.display = '';
out.innerHTML = `<div class="http-test-result http-test-fail">
<span class="http-test-badge http-test-badge-fail">${escapeHtml(t('http_endpoint.test.failed'))}</span>
<code class="http-test-error">${escapeHtml(msg)}</code>
</div>`;
};
if (!url) {
renderValidationFail(t('http_endpoint.error.url_required'));
return;
}
if (isNaN(timeout_s) || timeout_s <= 0) {
renderValidationFail(t('http_endpoint.error.timeout_invalid'));
return;
}
const testBtn = document.getElementById('http-endpoint-test-btn');
if (testBtn) testBtn.classList.add('loading');
out.style.display = '';
out.innerHTML = `<div class="http-test-pending">
<span class="http-test-pending-spinner" aria-hidden="true"></span>
<span>${escapeHtml(t('http_endpoint.test.pending'))}</span>
</div>`;
try {
const resp = await fetchWithAuth('/http/endpoints/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, method, auth_token: token, headers, timeout_s }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data: HTTPTestResponse = await resp.json();
_renderTestResult(out, data);
} catch (e: any) {
if (e.isAuth) return;
out.innerHTML = _renderTestErrorHtml(e.message);
} finally {
if (testBtn) testBtn.classList.remove('loading');
}
}
function _renderTestErrorHtml(message: string): string {
return `<div class="http-test-result http-test-fail">
<div class="http-test-line">
<span class="http-test-badge http-test-badge-fail">${ICON_WARNING}<span>${escapeHtml(t('http_endpoint.test.failed'))}</span></span>
</div>
<code class="http-test-error">${escapeHtml(message)}</code>
</div>`;
}
function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) {
const statusClass = data.success ? 'http-test-ok' : 'http-test-fail';
const badgeClass = data.success ? 'http-test-badge-ok' : 'http-test-badge-fail';
const badgeIcon = data.success ? ICON_OK : ICON_WARNING;
const badgeText = data.success
? t('http_endpoint.test.success')
: t('http_endpoint.test.failed');
const statusLine = data.status_code != null
? `<span class="http-test-status" title="HTTP ${data.status_code}">${data.status_code}</span>`
: '';
const errLine = data.error && !data.success
? `<code class="http-test-error">${escapeHtml(data.error)}</code>`
: '';
// Show JSON body when available — easier to copy a json_path from than raw text.
let bodyHtml = '';
let bodyLabel = '';
if (data.body_json != null) {
let pretty: string;
try { pretty = JSON.stringify(data.body_json, null, 2); }
catch { pretty = String(data.body_json); }
bodyLabel = `<div class="http-test-body-label">${escapeHtml(t('http_endpoint.test.body.json'))}</div>`;
bodyHtml = `<pre class="http-test-body">${escapeHtml(pretty)}</pre>`;
} else if (data.body_preview) {
bodyLabel = `<div class="http-test-body-label">${escapeHtml(t('http_endpoint.test.body.text'))}</div>`;
bodyHtml = `<pre class="http-test-body">${escapeHtml(data.body_preview)}</pre>`;
}
out.innerHTML = `<div class="http-test-result ${statusClass}">
<div class="http-test-line">
<span class="http-test-badge ${badgeClass}">${badgeIcon}<span>${escapeHtml(badgeText)}</span></span>
${statusLine}
</div>
${errLine}
${bodyLabel}
${bodyHtml}
</div>`;
}
// ── Card-level test (uses stored config, no form needed) ──────
async function _testHTTPEndpointFromCard(endpointId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const data: HTTPTestResponse = await resp.json();
if (data.success) {
const status = data.status_code != null ? ` (${data.status_code})` : '';
showToast(`${t('http_endpoint.test.success')}${status}`, 'success');
} else {
const detail = data.error || `HTTP ${data.status_code ?? '?'}`;
showToast(`${t('http_endpoint.test.failed')}: ${detail}`, 'error');
}
} catch (e: any) {
if (e.isAuth) return;
showToast(`${t('http_endpoint.test.failed')}: ${e.message}`, 'error');
}
}
// ── Card rendering ────────────────────────────────────────────
export function createHTTPEndpointCard(endpoint: HTTPEndpoint) {
const hasAuth = !!endpoint.auth_token_set;
const headerCount = Object.keys(endpoint.headers || {}).length;
const leds: LedState[] = ['on'];
const chips: ModChipOpts[] = [
{
icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`,
text: endpoint.url,
title: endpoint.url,
},
{
icon: `<svg class="icon" viewBox="0 0 24 24">${P.refreshCw}</svg>`,
text: endpoint.method,
},
];
if (hasAuth) {
chips.push({
icon: `<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg>`,
text: t('http_endpoint.auth.set'),
});
}
if (headerCount > 0) {
chips.push({
icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`,
text: t('http_endpoint.headers.count').replace('{n}', String(headerCount)),
});
}
const mod: ModCardOpts = {
head: {
badge: { text: 'HTTP · ENDPOINT' },
name: endpoint.name,
metaHtml: escapeHtml(endpoint.url),
leds,
...makeCardIconFields('http_endpoint', endpoint.id, endpoint),
menu: {
duplicateOnclick: `cloneHTTPEndpoint('${endpoint.id}')`,
hideOnclick: `toggleCardHidden('http-endpoints','${endpoint.id}')`,
deleteOnclick: `deleteHTTPEndpoint('${endpoint.id}')`,
},
},
body: {
desc: endpoint.description || undefined,
chips,
},
foot: {
patchState: 'idle',
patchLabel: `${endpoint.method} · ${Math.round(endpoint.timeout_s)}s`,
iconActions: [
{ icon: ICON_TEST, onclick: '', title: t('http_endpoint.test'), dataAttrs: { 'data-action': 'test' } },
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } },
],
},
running: false,
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: endpoint.id, mod });
const tagsHtml = renderTagChips(endpoint.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}
// ── Event delegation ──────────────────────────────────────────
const _httpEndpointActions: Record<string, (id: string) => void> = {
clone: cloneHTTPEndpoint,
edit: editHTTPEndpoint,
test: _testHTTPEndpointFromCard,
};
export function initHTTPEndpointDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const section = btn.closest<HTMLElement>('[data-card-section="http-endpoints"]');
if (!section) return;
const card = btn.closest<HTMLElement>('[data-id]');
if (!card) return;
const action = btn.dataset.action;
const id = card.getAttribute('data-id');
if (!action || !id) return;
const handler = _httpEndpointActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ── Expose to global scope for HTML onclick handlers ──────────
window.showHTTPEndpointModal = showHTTPEndpointModal;
window.closeHTTPEndpointModal = closeHTTPEndpointModal;
window.saveHTTPEndpoint = saveHTTPEndpoint;
window.editHTTPEndpoint = editHTTPEndpoint;
window.cloneHTTPEndpoint = cloneHTTPEndpoint;
window.deleteHTTPEndpoint = deleteHTTPEndpoint;
window.testHTTPEndpoint = testHTTPEndpoint;
window.addHTTPEndpointHeader = addHTTPEndpointHeader;
window.toggleHTTPEndpointTokenVisibility = toggleHTTPEndpointTokenVisibility;
@@ -4,9 +4,9 @@
*/
import {
_cachedWeatherSources, _cachedHASources, _cachedMQTTSources,
_cachedWeatherSources, _cachedHASources, _cachedMQTTSources, _cachedHTTPEndpoints,
_cachedGameIntegrations, _cachedGameAdapters,
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
weatherSourcesCache, haSourcesCache, mqttSourcesCache, httpEndpointsCache,
gameIntegrationsCache, gameAdaptersCache,
apiKey,
} from '../core/state.ts';
@@ -20,6 +20,7 @@ import { showToast, setTabRefreshing } from '../core/ui.ts';
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
import { createMQTTSourceCard, initMQTTSourceDelegation } from './mqtt-sources.ts';
import { createHTTPEndpointCard, initHTTPEndpointDelegation } from './http-endpoints.ts';
import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts';
import { ICON_GAMEPAD, ICON_TRASH, ICON_HELP } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
@@ -42,12 +43,14 @@ function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) {
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }];
const _mqttSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('mqtt/sources', mqttSourcesCache, 'mqtt_source.deleted') }];
const _httpEndpointDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('http/endpoints', httpEndpointsCache, 'http_endpoint.deleted') }];
// ── Card section instances ──
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction });
const csMQTTSources = new CardSection('mqtt-sources', { titleKey: 'mqtt_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showMQTTSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.mqtt_sources', bulkActions: _mqttSourceDeleteAction });
const csHTTPEndpoints = new CardSection('http-endpoints', { titleKey: 'http_endpoint.group.title', gridClass: 'templates-grid', addCardOnclick: "showHTTPEndpointModal()", keyAttr: 'data-id', emptyKey: 'section.empty.http_endpoints', bulkActions: _httpEndpointDeleteAction });
// Re-render integrations when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadIntegrations(); });
@@ -87,6 +90,7 @@ const _integrationSectionMap: Record<string, CardSection[]> = {
weather: [csWeatherSources],
home_assistant: [csHASources],
mqtt: [csMQTTSources],
http: [csHTTPEndpoints],
game: [csGameIntegrations],
};
@@ -101,6 +105,7 @@ export async function loadIntegrations() {
weatherSourcesCache.fetch(),
haSourcesCache.fetch(),
mqttSourcesCache.fetch(),
httpEndpointsCache.fetch(),
gameIntegrationsCache.fetch(),
gameAdaptersCache.fetch(),
]);
@@ -127,6 +132,7 @@ function renderIntegrationsList() {
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
{ key: 'mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, titleKey: 'streams.group.mqtt', count: _cachedMQTTSources.length },
{ key: 'http', icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`, titleKey: 'streams.group.http', count: _cachedHTTPEndpoints.length },
{ key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length },
];
@@ -135,6 +141,7 @@ function renderIntegrationsList() {
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
{ key: 'mqtt', titleKey: 'streams.group.mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, count: _cachedMQTTSources.length },
{ key: 'http', titleKey: 'streams.group.http', icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`, count: _cachedHTTPEndpoints.length },
{ key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length },
];
@@ -142,6 +149,7 @@ function renderIntegrationsList() {
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) })));
const mqttSourceItems = csMQTTSources.applySortOrder(_cachedMQTTSources.map(s => ({ key: s.id, html: createMQTTSourceCard(s) })));
const httpEndpointItems = csHTTPEndpoints.applySortOrder(_cachedHTTPEndpoints.map(e => ({ key: e.id, html: createHTTPEndpointCard(e) })));
const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) })));
if (csWeatherSources.isMounted()) {
@@ -150,11 +158,13 @@ function renderIntegrationsList() {
weather: _cachedWeatherSources.length,
home_assistant: _cachedHASources.length,
mqtt: _cachedMQTTSources.length,
http: _cachedHTTPEndpoints.length,
game: _cachedGameIntegrations.length,
});
csWeatherSources.reconcile(weatherSourceItems);
csHASources.reconcile(haSourceItems);
csMQTTSources.reconcile(mqttSourceItems);
csHTTPEndpoints.reconcile(httpEndpointItems);
csGameIntegrations.reconcile(gameIntegrationItems);
} else {
// First render: build full HTML
@@ -163,17 +173,19 @@ function renderIntegrationsList() {
if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
else if (tab.key === 'mqtt') panelContent = csMQTTSources.render(mqttSourceItems);
else if (tab.key === 'http') panelContent = csHTTPEndpoints.render(httpEndpointItems);
else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="integration-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csGameIntegrations]);
CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csHTTPEndpoints, csGameIntegrations]);
// Event delegation for card actions
initWeatherSourceDelegation(container);
initHASourceDelegation(container);
initMQTTSourceDelegation(container);
initHTTPEndpointDelegation(container);
// Render tree sidebar with tutorial trigger button
_integrationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startIntegrationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
@@ -182,6 +194,7 @@ function renderIntegrationsList() {
'weather-sources': 'weather',
'ha-sources': 'home_assistant',
'mqtt-sources': 'mqtt',
'http-endpoints': 'http',
'game-integrations': 'game',
});
}
@@ -115,6 +115,8 @@ export async function closeMQTTSourceModal(): Promise<void> {
export async function saveMQTTSource(): Promise<void> {
const id = (document.getElementById('mqtt-source-id') as HTMLInputElement).value;
if (mqttSourceModal.closeIfPristine(id)) return;
const name = (document.getElementById('mqtt-source-name') as HTMLInputElement).value.trim();
const broker_host = (document.getElementById('mqtt-source-host') as HTMLInputElement).value.trim();
const broker_port = parseInt((document.getElementById('mqtt-source-port') as HTMLInputElement).value, 10) || 1883;
@@ -240,6 +240,8 @@ export async function savePatternTemplate(): Promise<void> {
}
const templateId = (document.getElementById('pattern-template-id') as HTMLInputElement).value;
if (patternModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('pattern-template-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('pattern-template-description') as HTMLInputElement).value.trim();
@@ -369,6 +369,8 @@ export async function editScenePreset(presetId: string): Promise<void> {
// ===== Save (create or update) =====
export async function saveScenePreset(): Promise<void> {
if (scenePresetModal.closeIfPristine(_editingId)) return;
const name = (document.getElementById('scene-preset-editor-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('scene-preset-editor-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('scene-preset-editor-error')!;
@@ -23,6 +23,7 @@ import {
import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { enhanceMiniSelects } from '../core/mini-select.ts';
import { loadPictureSources } from './streams.ts';
import { openAuthedWs } from '../core/ws-auth.ts';
@@ -150,6 +151,11 @@ export async function onAudioEngineChange() {
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
// Convert the boolean toggles into MiniSelect popups — plain
// ``<select>`` is banned project-wide and these are the only
// selects rendered here that don't have a dedicated IconSelect
// configuration in CONFIG_ICON_SELECT.
enhanceMiniSelects(configFields, 'select[data-config-key]');
}
configSection.style.display = 'block';
@@ -262,6 +268,8 @@ export async function closeAudioTemplateModal() {
export async function saveAudioTemplate() {
const templateId = currentEditingAudioTemplateId;
if (audioTemplateModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('audio-template-name') as HTMLInputElement).value.trim();
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
@@ -25,6 +25,7 @@ import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { openAuthedWs } from '../core/ws-auth.ts';
import { IconSelect } from '../core/icon-select.ts';
import { enhanceMiniSelects } from '../core/mini-select.ts';
import { loadPictureSources } from './streams.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -330,6 +331,12 @@ export async function onEngineChange() {
_configIconSelects.set(key, inst);
}
}
// Everything else (booleans + free-form selectOptions without a
// CONFIG_ICON_SELECT entry) gets a MiniSelect so plain ``<select>``
// never reaches the user. ``enhanceMiniSelects`` skips elements
// already hidden by IconSelect so this is safe to run after the
// loop above.
enhanceMiniSelects(configFields, 'select[data-config-key]');
}
configSection.style.display = 'block';
@@ -584,6 +591,8 @@ export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessag
export async function saveTemplate() {
const templateId = (document.getElementById('template-id') as HTMLInputElement).value;
if (templateModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('template-name') as HTMLInputElement).value.trim();
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
@@ -749,7 +749,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length },
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
{ key: 'color_strip', icon: getColorStripIcon('single_color'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
{ key: 'gradients', icon: ICON_PALETTE, titleKey: 'streams.group.gradients', count: gradients.length },
{ key: 'audio_capture', icon: getAudioSourceIcon('capture'), titleKey: 'audio_source.group.capture', count: captureSources.length },
{ key: 'audio_processed', icon: getAudioSourceIcon('processed'), titleKey: 'audio_source.group.processed', count: processedAudioSources.length },
@@ -789,9 +789,9 @@ function renderPictureSourcesList(streams: any) {
]
},
{
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
key: 'strip_group', icon: getColorStripIcon('single_color'), titleKey: 'tree.group.strip',
children: [
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length },
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('single_color'), count: colorStrips.length },
{ key: 'gradients', titleKey: 'streams.group.gradients', icon: ICON_PALETTE, count: gradients.length },
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
]
@@ -1460,6 +1460,8 @@ async function _refreshStreamDisplaysForEngine(engineType: any) {
export async function saveStream() {
const streamId = (document.getElementById('stream-id') as HTMLInputElement).value;
if (streamModal.closeIfPristine(streamId)) return;
const name = (document.getElementById('stream-name') as HTMLInputElement).value.trim();
const streamType = (document.getElementById('stream-type') as HTMLSelectElement).value;
const description = (document.getElementById('stream-description') as HTMLInputElement).value.trim();
@@ -2050,6 +2052,8 @@ export async function editPPTemplate(templateId: any) {
export async function savePPTemplate() {
const templateId = (document.getElementById('pp-template-id') as HTMLInputElement).value;
if (ppTemplateModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('pp-template-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('pp-template-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('pp-template-error')!;
@@ -2265,6 +2269,8 @@ export async function editCSPT(templateId: any) {
export async function saveCSPT() {
const templateId = (document.getElementById('cspt-id') as HTMLInputElement).value;
if (csptModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('cspt-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('cspt-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('cspt-error')!;
@@ -110,6 +110,8 @@ export async function closeSyncClockModal(): Promise<void> {
export async function saveSyncClock(): Promise<void> {
const id = (document.getElementById('sync-clock-id') as HTMLInputElement).value;
if (syncClockModal.closeIfPristine(id)) return;
const name = (document.getElementById('sync-clock-name') as HTMLInputElement).value.trim();
const speed = parseFloat((document.getElementById('sync-clock-speed') as HTMLInputElement).value);
const description = (document.getElementById('sync-clock-description') as HTMLInputElement).value.trim() || null;
@@ -496,6 +496,8 @@ export function forceCloseTargetEditorModal() {
export async function saveTargetEditor() {
const targetId = (document.getElementById('target-editor-id') as HTMLInputElement).value;
if (targetEditorModal.closeIfPristine(targetId)) return;
const name = (document.getElementById('target-editor-name') as HTMLInputElement).value.trim();
const deviceId = (document.getElementById('target-editor-device') as HTMLSelectElement).value;
const standbyInterval = parseFloat((document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value);
@@ -15,6 +15,7 @@ import {
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
_cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache,
_cachedSyncClocks, syncClocksCache,
_cachedHTTPEndpoints, httpEndpointsCache,
getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
@@ -74,6 +75,7 @@ let _vsGradientEasingIconSelect: IconSelect | null = null;
let _vsBehaviorIconSelect: IconSelect | null = null;
let _vsMetricIconSelect: IconSelect | null = null;
let _vsAnimColorClockEntitySelect: EntitySelect | null = null;
let _vsHTTPEndpointEntitySelect: EntitySelect | null = null;
let _vsTagsInput: TagInput | null = null;
class ValueSourceModal extends Modal {
@@ -92,6 +94,7 @@ class ValueSourceModal extends Modal {
if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; }
if (_vsAnimColorClockEntitySelect) { _vsAnimColorClockEntitySelect.destroy(); _vsAnimColorClockEntitySelect = null; }
if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; }
if (_vsHTTPEndpointEntitySelect) { _vsHTTPEndpointEntitySelect.destroy(); _vsHTTPEndpointEntitySelect = null; }
}
snapshotValues() {
@@ -136,6 +139,13 @@ class ValueSourceModal extends Modal {
sensorLabel: (document.getElementById('value-source-sensor-label') as HTMLInputElement).value,
pollInterval: (document.getElementById('value-source-poll-interval') as HTMLInputElement).value,
sysmetricSmoothing: (document.getElementById('value-source-sysmetric-smoothing') as HTMLInputElement).value,
// HTTP value source
httpEndpoint: (document.getElementById('value-source-http-endpoint') as HTMLSelectElement | null)?.value || '',
httpJsonPath: (document.getElementById('value-source-http-json-path') as HTMLInputElement | null)?.value || '',
httpInterval: (document.getElementById('value-source-http-interval') as HTMLInputElement | null)?.value || '',
httpMin: (document.getElementById('value-source-http-min') as HTMLInputElement | null)?.value || '',
httpMax: (document.getElementById('value-source-http-max') as HTMLInputElement | null)?.value || '',
httpSmoothing: (document.getElementById('value-source-http-smoothing') as HTMLInputElement | null)?.value || '',
};
}
}
@@ -168,13 +178,17 @@ function _autoGenerateVSName() {
} else if (type === 'game_event') {
const eventType = (document.getElementById('value-source-game-event-type') as HTMLSelectElement)?.value;
if (eventType) detail = eventType;
} else if (type === 'http') {
const sel = document.getElementById('value-source-http-endpoint') as HTMLSelectElement | null;
const name = sel?.selectedOptions[0]?.textContent?.trim();
if (name) detail = name;
}
(document.getElementById('value-source-name') as HTMLInputElement).value = detail ? `${typeLabel} · ${detail}` : typeLabel;
}
/* ── Icon-grid type selector ──────────────────────────────────── */
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event'];
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event', 'http'];
const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract'];
const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS];
@@ -446,6 +460,31 @@ function _ensureVSTypeIconSelect() {
_vsTypeIconSelect = new IconSelect({ target: sel, items: _buildVSTypeItems(), columns: 2 } as any);
}
/* ── HTTP endpoint picker (EntitySelect over httpEndpointsCache) ── */
function _populateVSHTTPEndpointDropdown(selectedId: string = '') {
const sel = document.getElementById('value-source-http-endpoint') as HTMLSelectElement;
if (!sel) return;
const endpoints = _cachedHTTPEndpoints || [];
const prev = selectedId || sel.value;
sel.innerHTML = `<option value="">—</option>` +
endpoints.map(e => `<option value="${e.id}"${e.id === prev ? ' selected' : ''}>${escapeHtml(e.name)}</option>`).join('');
sel.value = prev || '';
if (_vsHTTPEndpointEntitySelect) _vsHTTPEndpointEntitySelect.destroy();
_vsHTTPEndpointEntitySelect = new EntitySelect({
target: sel,
getItems: () => (_cachedHTTPEndpoints || []).map(e => ({
value: e.id,
label: e.name,
icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`,
desc: `${e.method} ${e.url}`,
})),
placeholder: t('palette.search'),
onChange: () => _autoGenerateVSName(),
} as any);
}
// ── Modal ─────────────────────────────────────────────────────
export async function showValueSourceModal(editData: any, presetType: any = null) {
@@ -586,6 +625,14 @@ export async function showValueSourceModal(editData: any, presetType: any = null
_setSlider('value-source-ge-smoothing', editData.smoothing ?? 0);
_setSlider('value-source-ge-default', editData.default_value ?? 0.5);
_setSlider('value-source-ge-timeout', editData.timeout ?? 5.0);
} else if (editData.source_type === 'http') {
await httpEndpointsCache.fetch();
_populateVSHTTPEndpointDropdown(editData.http_endpoint_id || '');
(document.getElementById('value-source-http-json-path') as HTMLInputElement).value = editData.json_path || '';
(document.getElementById('value-source-http-interval') as HTMLInputElement).value = String(editData.interval_s ?? 60);
(document.getElementById('value-source-http-min') as HTMLInputElement).value = String(editData.min_value ?? 0);
(document.getElementById('value-source-http-max') as HTMLInputElement).value = String(editData.max_value ?? 100);
_setSlider('value-source-http-smoothing', editData.smoothing ?? 0);
}
} else {
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
@@ -650,6 +697,16 @@ export async function showValueSourceModal(editData: any, presetType: any = null
(document.getElementById('value-source-sensor-label') as HTMLInputElement).value = '';
_setSlider('value-source-poll-interval', 1.0);
_setSlider('value-source-sysmetric-smoothing', 0);
// HTTP value source defaults
const httpJsonPath = document.getElementById('value-source-http-json-path') as HTMLInputElement | null;
if (httpJsonPath) httpJsonPath.value = '';
const httpInterval = document.getElementById('value-source-http-interval') as HTMLInputElement | null;
if (httpInterval) httpInterval.value = '60';
const httpMin = document.getElementById('value-source-http-min') as HTMLInputElement | null;
if (httpMin) httpMin.value = '0';
const httpMax = document.getElementById('value-source-http-max') as HTMLInputElement | null;
if (httpMax) httpMax.value = '100';
_setSlider('value-source-http-smoothing', 0);
_autoGenerateVSName();
}
@@ -699,6 +756,13 @@ export function onValueSourceTypeChange() {
if (type === 'game_event') {
_populateVSGameIntegrationDropdown('');
}
const httpSec = document.getElementById('value-source-http-section') as HTMLElement | null;
if (httpSec) httpSec.style.display = type === 'http' ? '' : 'none';
if (type === 'http') {
// Refresh endpoint list lazily — value-source modal can be opened
// before the integrations tab has been visited.
httpEndpointsCache.fetch().then(() => _populateVSHTTPEndpointDropdown(''));
}
(document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
@@ -755,6 +819,8 @@ function _syncDaylightVSSpeedVisibility() {
export async function saveValueSource() {
const id = (document.getElementById('value-source-id') as HTMLInputElement).value;
if (valueSourceModal.closeIfPristine(id)) return;
const name = (document.getElementById('value-source-name') as HTMLInputElement).value.trim();
const sourceType = (document.getElementById('value-source-type') as HTMLSelectElement).value;
const description = (document.getElementById('value-source-description') as HTMLInputElement).value.trim() || null;
@@ -879,6 +945,23 @@ export async function saveValueSource() {
errorEl.style.display = '';
return;
}
} else if (sourceType === 'http') {
payload.http_endpoint_id = (document.getElementById('value-source-http-endpoint') as HTMLSelectElement).value;
payload.json_path = (document.getElementById('value-source-http-json-path') as HTMLInputElement).value.trim();
payload.interval_s = parseInt((document.getElementById('value-source-http-interval') as HTMLInputElement).value, 10) || 60;
payload.min_value = parseFloat((document.getElementById('value-source-http-min') as HTMLInputElement).value) || 0;
payload.max_value = parseFloat((document.getElementById('value-source-http-max') as HTMLInputElement).value) || 100;
payload.smoothing = parseFloat((document.getElementById('value-source-http-smoothing') as HTMLInputElement).value) || 0;
if (!payload.http_endpoint_id) {
errorEl.textContent = t('value_source.http.endpoint_required');
errorEl.style.display = '';
return;
}
if (payload.interval_s < 1) {
errorEl.textContent = t('value_source.http.interval_invalid');
errorEl.style.display = '';
return;
}
}
try {
@@ -146,6 +146,8 @@ export async function closeWeatherSourceModal(): Promise<void> {
export async function saveWeatherSource(): Promise<void> {
const id = (document.getElementById('weather-source-id') as HTMLInputElement).value;
if (weatherSourceModal.closeIfPristine(id)) return;
const name = (document.getElementById('weather-source-name') as HTMLInputElement).value.trim();
const provider = (document.getElementById('weather-source-provider') as HTMLSelectElement).value;
const latitude = parseFloat((document.getElementById('weather-source-latitude') as HTMLInputElement).value) || 50.0;
@@ -18,6 +18,7 @@ import {
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { logError } from '../core/log.ts';
import { safeJsonParse } from '../core/storage.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
@@ -161,25 +162,35 @@ export function addZ2MLightMapping(data: any = null): void {
// Per-source-kind row layout: CSS shows LED ranges; color_vs hides them
// and promotes the brightness scale to a single inline field.
// Numeric fields are coerced via ``Number()`` so a hostile/stale JSON
// mapping (where a number got serialised as a string with markup) can't
// smuggle HTML into the attribute context. ``Number.isFinite`` filters
// ``NaN`` so the fallback always renders a sane default.
const brightnessVal = Number.isFinite(Number(data?.brightness_scale))
? Number(data?.brightness_scale)
: 1.0;
const ledStartVal = Number.isFinite(Number(data?.led_start)) ? Number(data?.led_start) : 0;
const ledEndVal = Number.isFinite(Number(data?.led_end)) ? Number(data?.led_end) : -1;
const rangeBlock = _editorSourceKind === 'color_vs'
? `<div class="ha-mapping-range-row">
<div>
<label>${t('z2m_light.mapping.brightness')}</label>
<input type="number" class="z2m-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
<input type="number" class="z2m-mapping-brightness" value="${brightnessVal}" min="0" max="1" step="0.1">
</div>
</div>`
: `<div class="ha-mapping-range-row">
<div>
<label>${t('z2m_light.mapping.led_start')}</label>
<input type="number" class="z2m-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
<input type="number" class="z2m-mapping-led-start" value="${ledStartVal}" min="0" step="1">
</div>
<div>
<label>${t('z2m_light.mapping.led_end')}</label>
<input type="number" class="z2m-mapping-led-end" value="${data?.led_end ?? -1}" min="-1" step="1">
<input type="number" class="z2m-mapping-led-end" value="${ledEndVal}" min="-1" step="1">
</div>
<div>
<label>${t('z2m_light.mapping.brightness')}</label>
<input type="number" class="z2m-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
<input type="number" class="z2m-mapping-brightness" value="${brightnessVal}" min="0" max="1" step="0.1">
</div>
</div>`;
@@ -224,7 +235,10 @@ export function removeZ2MLightMapping(btn: HTMLElement): void {
function _rerenderMappingsForMode(): void {
const list = document.getElementById('z2m-light-mappings-list');
if (!list) return;
const snapshot = JSON.parse(_getMappingsJSON());
// Guarded JSON parse — _getMappingsJSON reads live DOM values so a
// partially-rendered row (or hand-edited input) used to throw and
// wipe the editor mid-toggle. ``safeJsonParse`` falls back to [].
const snapshot = safeJsonParse<unknown[]>(_getMappingsJSON(), []);
list.innerHTML = '';
snapshot.forEach((m: any) => addZ2MLightMapping(m));
_setMappingsModeHint();
@@ -390,6 +404,8 @@ export async function closeZ2MLightEditor(): Promise<void> {
export async function saveZ2MLightEditor(): Promise<void> {
const targetId = (document.getElementById('z2m-light-editor-id') as HTMLInputElement).value;
if (z2mLightEditorModal.closeIfPristine(targetId)) return;
const name = (document.getElementById('z2m-light-editor-name') as HTMLInputElement).value.trim();
const mqttSourceId = (document.getElementById('z2m-light-editor-mqtt-source') as HTMLSelectElement).value;
const baseTopic = (document.getElementById('z2m-light-editor-base-topic') as HTMLInputElement).value.trim() || 'zigbee2mqtt';
@@ -420,7 +436,8 @@ export async function saveZ2MLightEditor(): Promise<void> {
return;
}
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.friendly_name);
const mappings = safeJsonParse<Array<Record<string, unknown>>>(_getMappingsJSON(), [])
.filter((m) => m && typeof m === 'object' && 'friendly_name' in m && (m as { friendly_name: unknown }).friendly_name);
if (mappings.length === 0) {
z2mLightEditorModal.showError(t('z2m_light.error.mapping_required') || 'At least one bulb mapping is required');
return;
@@ -713,10 +730,8 @@ export function connectZ2MLightWS(targetId: string): void {
openAuthedWs(url).then((ws) => {
_z2mLightWS[targetId] = ws;
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors);
} catch (err) { logError('z2m-light-targets.ws.message', err); }
const data = safeJsonParse<{ type?: string; colors?: unknown }>(ev.data, {});
if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors as never);
};
ws.onclose = () => { delete _z2mLightWS[targetId]; };
ws.onerror = () => { delete _z2mLightWS[targetId]; };
+68
View File
@@ -0,0 +1,68 @@
/**
* Ambient declarations for the project's `window` globals.
*
* Several legacy modules attach helpers onto `window` so they can be
* called from HTML ``onclick`` attributes / global tab-registry lookups.
* Without these declarations every callsite used ``(window as any).foo``,
* which silently erases type errors at the call boundary. Declaring the
* known fields here lets `tsc --noEmit` flag real typos while keeping
* the call sites readable.
*
* The list is the minimal subset that covers the ``(window as any).<name>``
* sites flagged by the cross-file audit. Anything indexed by dynamic
* string ($name = `${kind}Foo`) still legitimately needs an indexed
* access those cases keep their narrow casts.
*/
export {};
declare global {
interface Window {
// Auth / setup gates (set from core/api.ts during boot)
_authRequired?: boolean;
_setupRequired?: boolean;
_setupModalOpen?: boolean;
// i18n shim — present once core/i18n.ts initialises.
__t?: (key: string) => string;
// UI helpers exposed for inline onclick handlers in templates.
applyAccentColor?: () => void;
hideSetupRequiredModal?: () => void;
configureKCRegions?: (sourceId: string) => void;
removeZ2MLightMapping?: (btn: HTMLElement) => void;
// Feature reloaders. They are called from cross-feature code that
// doesn't want a hard module import (to avoid cycles).
loadAutomations?: () => Promise<void> | void;
loadPictureSources?: () => Promise<void> | void;
showPatternTemplateEditor?: (...args: unknown[]) => unknown;
// Internal helpers attached by features for inline-call reuse.
_autoGenerateAutomationName?: () => void;
_openKCRegionEditor?: (sourceId: string) => void;
// HTTP endpoint editor — used by the integrations tab and
// automation rule editor.
showHTTPEndpointModal?: (editData?: unknown) => Promise<void>;
closeHTTPEndpointModal?: () => Promise<void>;
saveHTTPEndpoint?: () => Promise<void>;
editHTTPEndpoint?: (id: string) => Promise<void>;
cloneHTTPEndpoint?: (id: string) => Promise<void>;
deleteHTTPEndpoint?: (id: string) => Promise<void>;
testHTTPEndpoint?: () => Promise<void>;
addHTTPEndpointHeader?: () => void;
toggleHTTPEndpointTokenVisibility?: () => void;
// HA / MQTT source editors — declared so app.ts assignments
// satisfy strict mode. (Not exhaustive; only what http-endpoints
// wires up.)
showHASourceModal?: (...args: unknown[]) => unknown;
closeHASourceModal?: () => Promise<void>;
saveHASource?: () => Promise<void>;
editHASource?: (id: string) => Promise<void>;
cloneHASource?: (id: string) => Promise<void>;
deleteHASource?: (id: string) => Promise<void>;
testHASource?: () => Promise<void>;
}
}
+107 -40
View File
@@ -3,45 +3,26 @@
*
* These mirror the JSON shapes returned by the REST API. Field names use
* snake_case to match the JSON payloads no camelCase transformation is done.
*
* Bindable primitives have been extracted into ``types/bindable.ts`` and
* are re-exported here so existing ``import { ... } from '../types.ts'``
* call sites keep working. The intention is for further entity-shape
* groups (devices, sources, integrations, ) to follow the same pattern
* in subsequent passes see audit finding H6.
*/
// ── Bindable Float ───────────────────────────────────────────
// A scalar that is either a static value (plain number) or bound to a value source (dict).
// ── Bindable Primitives ─────────────────────────────────────
export type { BindableFloat, BindableColor } from './types/bindable.ts';
export {
bindableValue,
bindableSourceId,
bindableColor,
bindableColorSourceId,
} from './types/bindable.ts';
export type BindableFloat = number | { value: number; source_id: string };
/** Extract the static value from a BindableFloat. */
export function bindableValue(b: BindableFloat | undefined, fallback: number): number {
if (b === undefined || b === null) return fallback;
if (typeof b === 'number') return b;
return b.value ?? fallback;
}
/** Extract the source_id from a BindableFloat (empty string = not bound). */
export function bindableSourceId(b: BindableFloat | undefined): string {
if (b === undefined || b === null) return '';
if (typeof b === 'number') return '';
return b.source_id ?? '';
}
// ── Bindable Color ──────────────────────────────────────────
// An RGB color that is either static ([R,G,B] array) or bound to a color value source.
export type BindableColor = number[] | { color: number[]; source_id: string };
/** Extract the static [R,G,B] from a BindableColor. */
export function bindableColor(b: BindableColor | undefined, fallback: number[]): number[] {
if (b === undefined || b === null) return fallback;
if (Array.isArray(b)) return b;
return b.color ?? fallback;
}
/** Extract the source_id from a BindableColor (empty string = not bound). */
export function bindableColorSourceId(b: BindableColor | undefined): string {
if (b === undefined || b === null) return '';
if (Array.isArray(b)) return '';
return b.source_id ?? '';
}
// Local aliases used by the entity interfaces below so TypeScript can
// resolve them without an extra import at every reference site.
import type { BindableFloat, BindableColor } from './types/bindable.ts';
// ── Device ────────────────────────────────────────────────────
@@ -186,7 +167,7 @@ export type OutputTarget = LedOutputTarget | HALightOutputTarget | Z2MLightOutpu
// ── Color Strip Source ────────────────────────────────────────
export type CSSSourceType =
| 'picture' | 'picture_advanced' | 'static' | 'gradient'
| 'picture' | 'picture_advanced' | 'single_color' | 'gradient'
| 'effect' | 'composite' | 'mapped'
| 'audio' | 'api_input' | 'notification' | 'daylight'
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
@@ -379,7 +360,7 @@ export type ValueSourceType =
| 'adaptive_time' | 'adaptive_scene' | 'daylight'
| 'static_color' | 'animated_color' | 'adaptive_time_color'
| 'ha_entity' | 'gradient_map' | 'css_extract'
| 'system_metrics' | 'game_event';
| 'system_metrics' | 'game_event' | 'http';
export interface SchedulePoint {
time: string;
@@ -534,6 +515,17 @@ export interface GameEventValueSource extends ValueSourceBase {
timeout: number;
}
export interface HTTPValueSource extends ValueSourceBase {
source_type: 'http';
return_type: 'float';
http_endpoint_id: string;
json_path: string;
interval_s: number;
min_value: number;
max_value: number;
smoothing: number;
}
export type ValueSource =
| StaticValueSource
| AnimatedValueSource
@@ -548,7 +540,8 @@ export type ValueSource =
| GradientMapValueSource
| CSSExtractValueSource
| SystemMetricsValueSource
| GameEventValueSource;
| GameEventValueSource
| HTTPValueSource;
// ── Audio Source ───────────────────────────────────────────────
@@ -772,6 +765,68 @@ export interface MQTTStatusResponse {
connected_count: number;
}
// ── HTTP Endpoint ────────────────────────────────────────────
//
// A connection definition only (URL + auth + headers + timeout).
// No polling cadence is configured on the endpoint itself —
// HTTPValueSource owns interval_s and references the endpoint.
export type HTTPMethod = 'GET' | 'HEAD';
export interface HTTPEndpoint {
id: string;
name: string;
url: string;
method: HTTPMethod;
/** Server NEVER returns the token; this flag indicates one is stored. */
auth_token_set: boolean;
headers: Record<string, string>;
timeout_s: number;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface HTTPEndpointListResponse {
endpoints: HTTPEndpoint[];
count: number;
}
/** Wire payload for `POST /http/endpoints` / `PUT /http/endpoints/{id}`.
* All fields optional the route validates required-on-create separately. */
export interface HTTPEndpointWritePayload {
name?: string;
url?: string;
method?: HTTPMethod;
/** Plaintext token. PUT distinguishes None=keep / ""=clear; omit the field to keep. */
auth_token?: string;
headers?: Record<string, string>;
timeout_s?: number;
description?: string;
tags?: string[];
icon?: string;
icon_color?: string;
}
export interface HTTPTestRequest {
url: string;
method: HTTPMethod;
auth_token: string;
headers: Record<string, string>;
timeout_s: number;
}
export interface HTTPTestResponse {
success: boolean;
status_code?: number;
body_preview?: string;
body_json?: unknown;
error?: string;
}
// ── Asset ────────────────────────────────────────────────────
export interface Asset {
@@ -799,7 +854,12 @@ export interface AssetListResponse {
export type RuleType =
| 'application' | 'time_of_day' | 'system_idle'
| 'display_state' | 'mqtt' | 'webhook' | 'startup';
| 'display_state' | 'mqtt' | 'webhook' | 'startup'
| 'home_assistant' | 'http_poll';
export type HTTPPollOperator =
| 'equals' | 'not_equals' | 'contains' | 'regex'
| 'gt' | 'lt' | 'exists';
export interface AutomationRule {
rule_type: RuleType;
@@ -814,6 +874,13 @@ export interface AutomationRule {
payload?: string;
match_mode?: string;
token?: string;
/** home_assistant rule */
ha_source_id?: string;
entity_id?: string;
/** http_poll rule — references an HTTPValueSource. */
value_source_id?: string;
operator?: HTTPPollOperator;
value?: string;
}
export interface Automation {
@@ -0,0 +1,47 @@
/**
* Bindable scalar / colour primitives.
*
* A "bindable" value is either a plain literal or a reference to a value
* source (``{value | color, source_id}``). These types and the four
* accessor helpers are imported widely over 30 frontend modules read
* them through the ``types.ts`` barrel so they live in their own file
* to keep the entity-shape files free of helper-function noise.
*/
// ── Bindable Float ───────────────────────────────────────────
// A scalar that is either a static value (plain number) or bound to a value source (dict).
export type BindableFloat = number | { value: number; source_id: string };
/** Extract the static value from a BindableFloat. */
export function bindableValue(b: BindableFloat | undefined, fallback: number): number {
if (b === undefined || b === null) return fallback;
if (typeof b === 'number') return b;
return b.value ?? fallback;
}
/** Extract the source_id from a BindableFloat (empty string = not bound). */
export function bindableSourceId(b: BindableFloat | undefined): string {
if (b === undefined || b === null) return '';
if (typeof b === 'number') return '';
return b.source_id ?? '';
}
// ── Bindable Color ──────────────────────────────────────────
// An RGB color that is either static ([R,G,B] array) or bound to a color value source.
export type BindableColor = number[] | { color: number[]; source_id: string };
/** Extract the static [R,G,B] from a BindableColor. */
export function bindableColor(b: BindableColor | undefined, fallback: number[]): number[] {
if (b === undefined || b === null) return fallback;
if (Array.isArray(b)) return b;
return b.color ?? fallback;
}
/** Extract the source_id from a BindableColor (empty string = not bound). */
export function bindableColorSourceId(b: BindableColor | undefined): string {
if (b === undefined || b === null) return '';
if (Array.isArray(b)) return '';
return b.source_id ?? '';
}
+91 -7
View File
@@ -1352,7 +1352,7 @@
"color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles",
"color_strip.leds": "LED count",
"color_strip.led_count": "LED Count:",
"color_strip.led_count.hint": "Total number of LEDs on the physical strip. For screen sources: 0 = auto from calibration (extra LEDs not mapped to edges will be black). For static color: set to match your device LED count.",
"color_strip.led_count.hint": "Total number of LEDs on the physical strip. For screen sources: 0 = auto from calibration (extra LEDs not mapped to edges will be black). For single color: set to match your device LED count.",
"color_strip.created": "Color strip source created",
"color_strip.updated": "Color strip source updated",
"color_strip.deleted": "Color strip source deleted",
@@ -1360,17 +1360,17 @@
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
"color_strip.error.name_required": "Please enter a name",
"color_strip.type": "Type:",
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.",
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Single Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.",
"color_strip.type.picture": "Picture Source",
"color_strip.type.picture.desc": "Colors from screen capture",
"color_strip.type.picture_advanced": "Multi-Monitor",
"color_strip.type.picture_advanced.desc": "Line-based calibration across monitors",
"color_strip.type.static": "Static Color",
"color_strip.type.static.desc": "Single solid color fill",
"color_strip.type.single_color": "Single Color",
"color_strip.type.single_color.desc": "Single solid color fill",
"color_strip.type.gradient": "Gradient",
"color_strip.type.gradient.desc": "Smooth color transition across LEDs",
"color_strip.static_color": "Color:",
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip.",
"color_strip.single_color": "Color:",
"color_strip.single_color.hint": "The solid color that will be sent to all LEDs on the strip.",
"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.easing": "Easing:",
@@ -2961,5 +2961,89 @@
"pairing.success": "Paired successfully",
"pairing.not_ready": "Device didn't respond. Press the pairing button on your device, then try again.",
"pairing.failed": "Pairing failed: {detail}",
"pairing.failed_prefix": "Pairing failed:"
"pairing.failed_prefix": "Pairing failed:",
"streams.group.http": "HTTP",
"http_endpoint.group.title": "HTTP Endpoints",
"http_endpoint.add": "Add HTTP Endpoint",
"http_endpoint.edit": "Edit HTTP Endpoint",
"http_endpoint.section.request": "Request",
"http_endpoint.section.headers": "Headers",
"http_endpoint.name": "Name:",
"http_endpoint.name.placeholder": "Plex now-playing",
"http_endpoint.name.hint": "A descriptive name for this endpoint",
"http_endpoint.url": "URL:",
"http_endpoint.url.hint": "Full http(s) URL to poll. Local addresses are allowed.",
"http_endpoint.method": "Method:",
"http_endpoint.method.get.desc": "Fetch the response body.",
"http_endpoint.method.head.desc": "Status code only — no body. Great for liveness probes.",
"http_endpoint.auth_token": "Auth Token (optional):",
"http_endpoint.auth_token.hint": "Sent as 'Authorization: Bearer <token>'. Add a custom Authorization header to override.",
"http_endpoint.auth_token.edit_hint": "Leave blank to keep the current token",
"http_endpoint.auth_token.reveal": "Show / hide token",
"http_endpoint.auth.set": "Auth",
"http_endpoint.timeout": "Timeout (s):",
"http_endpoint.timeout.hint": "Maximum seconds to wait for a single request.",
"http_endpoint.test": "Test request",
"http_endpoint.test.pending": "Testing…",
"http_endpoint.test.success": "OK",
"http_endpoint.test.failed": "Failed",
"http_endpoint.test.body.json": "JSON body",
"http_endpoint.test.body.text": "Response body",
"http_endpoint.headers": "Custom Headers:",
"http_endpoint.headers.hint": "Optional request headers (e.g. X-API-Key, Accept).",
"http_endpoint.headers.add": "Add header",
"http_endpoint.headers.empty": "No custom headers — defaults will be sent.",
"http_endpoint.headers.name_placeholder": "Header name",
"http_endpoint.headers.value_placeholder": "Header value",
"http_endpoint.headers.count": "{n} headers",
"http_endpoint.description": "Description (optional):",
"http_endpoint.created": "HTTP endpoint created",
"http_endpoint.updated": "HTTP endpoint updated",
"http_endpoint.deleted": "HTTP endpoint deleted",
"http_endpoint.delete.confirm": "Delete this HTTP endpoint? Value sources that reference it will need to be repointed.",
"http_endpoint.error.name_required": "Name is required",
"http_endpoint.error.url_required": "URL is required",
"http_endpoint.error.timeout_invalid": "Timeout must be a positive number",
"http_endpoint.error.load": "Failed to load HTTP endpoint",
"section.empty.http_endpoints": "No HTTP endpoints yet. Click + to add one.",
"device.icon.entity.http_endpoint": "HTTP endpoint",
"value_source.type.http": "HTTP Poll",
"value_source.type.http.desc": "Polls an HTTP endpoint at a fixed cadence and maps the extracted value to 0-1.",
"value_source.http.endpoint": "HTTP Endpoint:",
"value_source.http.endpoint.hint": "Pick a saved endpoint from the HTTP integrations tab.",
"value_source.http.json_path": "JSON Path:",
"value_source.http.json_path.hint": "Empty = use raw response body. Use dotted/indexed path, e.g. MediaContainer.Metadata[0].title",
"value_source.http.interval": "Interval (s):",
"value_source.http.interval.hint": "Polling cadence in seconds. Multiple value sources can share one endpoint at different intervals.",
"value_source.http.min_value": "Min Value:",
"value_source.http.min_value.hint": "Raw extracted value that maps to output 0.0 (for normalisation).",
"value_source.http.max_value": "Max Value:",
"value_source.http.max_value.hint": "Raw extracted value that maps to output 1.0 (for normalisation).",
"value_source.http.modulator.summary": "Modulator mapping (optional)",
"value_source.http.modulator.hint": "Only used when this source drives brightness or color. Automation rules read the raw extracted value and ignore these settings.",
"value_source.http.endpoint_required": "HTTP endpoint is required",
"value_source.http.interval_invalid": "Interval must be at least 1 second",
"automations.rule.http_poll": "HTTP Poll",
"automations.rule.http_poll.desc": "Activate when the latest extracted value from an HTTP value source matches.",
"automations.rule.http_poll.hint": "Compares the latest extracted value against your input. The value source decides what gets extracted (raw body or JSON path).",
"automations.rule.http_poll.value_source": "HTTP Value Source",
"automations.rule.http_poll.operator": "Operator",
"automations.rule.http_poll.value": "Value",
"automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.no_source": "(no source)",
"automations.rule.http_poll.raw_body": "Raw body",
"automations.rule.http_poll.operator.equals": "Equals",
"automations.rule.http_poll.operator.equals.desc": "Exact string match.",
"automations.rule.http_poll.operator.not_equals": "Not equals",
"automations.rule.http_poll.operator.not_equals.desc": "Activates when the value is anything other than this.",
"automations.rule.http_poll.operator.contains": "Contains",
"automations.rule.http_poll.operator.contains.desc": "Substring match.",
"automations.rule.http_poll.operator.regex": "Regex",
"automations.rule.http_poll.operator.regex.desc": "JavaScript-style regular expression.",
"automations.rule.http_poll.operator.gt": "Greater than",
"automations.rule.http_poll.operator.gt.desc": "Numeric comparison (>) — requires numeric output.",
"automations.rule.http_poll.operator.lt": "Less than",
"automations.rule.http_poll.operator.lt.desc": "Numeric comparison (<) — requires numeric output.",
"automations.rule.http_poll.operator.exists": "Exists",
"automations.rule.http_poll.operator.exists.desc": "Activates whenever a value is successfully extracted (ignores the value)."
}
+90 -6
View File
@@ -1393,17 +1393,17 @@
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
"color_strip.error.name_required": "Введите название",
"color_strip.type": "Тип:",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Один цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.",
"color_strip.type.picture": "Источник изображения",
"color_strip.type.picture.desc": "Цвета из захвата экрана",
"color_strip.type.picture_advanced": "Мультимонитор",
"color_strip.type.picture_advanced.desc": "Калибровка линиями по нескольким мониторам",
"color_strip.type.static": "Статический цвет",
"color_strip.type.static.desc": "Заливка одним цветом",
"color_strip.type.single_color": "Один цвет",
"color_strip.type.single_color.desc": "Заливка одним цветом",
"color_strip.type.gradient": "Градиент",
"color_strip.type.gradient.desc": "Плавный переход цветов по ленте",
"color_strip.static_color": "Цвет:",
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.",
"color_strip.single_color": "Цвет:",
"color_strip.single_color.hint": "Один сплошной цвет, который будет отправлен на все светодиоды полосы.",
"color_strip.gradient.preview": "Градиент:",
"color_strip.gradient.preview.hint": "Предпросмотр градиента. Нажмите на дорожку маркеров чтобы добавить остановку. Перетащите маркеры для изменения позиции.",
"color_strip.gradient.stops": "Цветовые остановки:",
@@ -2641,5 +2641,89 @@
"pairing.success": "Успешно сопряжено",
"pairing.not_ready": "Устройство не ответило. Нажмите кнопку сопряжения на устройстве и попробуйте снова.",
"pairing.failed": "Сопряжение не удалось: {detail}",
"pairing.failed_prefix": "Сопряжение не удалось:"
"pairing.failed_prefix": "Сопряжение не удалось:",
"streams.group.http": "HTTP",
"http_endpoint.group.title": "HTTP-эндпоинты",
"http_endpoint.add": "Добавить HTTP-эндпоинт",
"http_endpoint.edit": "Изменить HTTP-эндпоинт",
"http_endpoint.section.request": "Запрос",
"http_endpoint.section.headers": "Заголовки",
"http_endpoint.name": "Имя:",
"http_endpoint.name.placeholder": "Plex now-playing",
"http_endpoint.name.hint": "Описательное имя для этого эндпоинта",
"http_endpoint.url": "URL:",
"http_endpoint.url.hint": "Полный http(s)-URL для опроса. Локальные адреса разрешены.",
"http_endpoint.method": "Метод:",
"http_endpoint.method.get.desc": "Получить тело ответа.",
"http_endpoint.method.head.desc": "Только код статуса — без тела. Подходит для проверки доступности.",
"http_endpoint.auth_token": "Токен авторизации (необязательно):",
"http_endpoint.auth_token.hint": "Отправляется как 'Authorization: Bearer <token>'. Добавьте свой Authorization-заголовок, чтобы переопределить.",
"http_endpoint.auth_token.edit_hint": "Оставьте пустым, чтобы сохранить текущий токен",
"http_endpoint.auth_token.reveal": "Показать / скрыть токен",
"http_endpoint.auth.set": "Авторизация",
"http_endpoint.timeout": "Таймаут (с):",
"http_endpoint.timeout.hint": "Максимальное время ожидания одного запроса (в секундах).",
"http_endpoint.test": "Тестовый запрос",
"http_endpoint.test.pending": "Проверка…",
"http_endpoint.test.success": "Успех",
"http_endpoint.test.failed": "Ошибка",
"http_endpoint.test.body.json": "JSON-тело",
"http_endpoint.test.body.text": "Тело ответа",
"http_endpoint.headers": "Заголовки:",
"http_endpoint.headers.hint": "Дополнительные заголовки запроса (например, X-API-Key, Accept).",
"http_endpoint.headers.add": "Добавить заголовок",
"http_endpoint.headers.empty": "Нет дополнительных заголовков — будут отправлены значения по умолчанию.",
"http_endpoint.headers.name_placeholder": "Имя заголовка",
"http_endpoint.headers.value_placeholder": "Значение",
"http_endpoint.headers.count": "{n} заголовков",
"http_endpoint.description": "Описание (необязательно):",
"http_endpoint.created": "HTTP-эндпоинт создан",
"http_endpoint.updated": "HTTP-эндпоинт обновлён",
"http_endpoint.deleted": "HTTP-эндпоинт удалён",
"http_endpoint.delete.confirm": "Удалить этот HTTP-эндпоинт? Источники-значений, ссылающиеся на него, потребуется перенастроить.",
"http_endpoint.error.name_required": "Имя обязательно",
"http_endpoint.error.url_required": "URL обязателен",
"http_endpoint.error.timeout_invalid": "Таймаут должен быть положительным числом",
"http_endpoint.error.load": "Не удалось загрузить HTTP-эндпоинт",
"section.empty.http_endpoints": "Пока нет HTTP-эндпоинтов. Нажмите +, чтобы добавить.",
"device.icon.entity.http_endpoint": "HTTP-эндпоинт",
"value_source.type.http": "HTTP-опрос",
"value_source.type.http.desc": "Периодически опрашивает HTTP-эндпоинт и сопоставляет извлечённое значение с диапазоном 0–1.",
"value_source.http.endpoint": "HTTP-эндпоинт:",
"value_source.http.endpoint.hint": "Выберите сохранённый эндпоинт во вкладке HTTP-интеграций.",
"value_source.http.json_path": "JSON-путь:",
"value_source.http.json_path.hint": "Пусто = использовать необработанное тело ответа. Используйте путь с точками и индексами, например MediaContainer.Metadata[0].title",
"value_source.http.interval": "Интервал (с):",
"value_source.http.interval.hint": "Период опроса в секундах. Несколько источников могут использовать один эндпоинт с разными интервалами.",
"value_source.http.min_value": "Мин. значение:",
"value_source.http.min_value.hint": "Извлечённое значение, которое отображается в выход 0.0 (для нормализации).",
"value_source.http.max_value": "Макс. значение:",
"value_source.http.max_value.hint": "Извлечённое значение, которое отображается в выход 1.0 (для нормализации).",
"value_source.http.modulator.summary": "Сопоставление для модулятора (необязательно)",
"value_source.http.modulator.hint": "Используется только когда этот источник управляет яркостью или цветом. Правила автоматизации читают извлечённое значение в исходном виде и игнорируют эти настройки.",
"value_source.http.endpoint_required": "Требуется HTTP-эндпоинт",
"value_source.http.interval_invalid": "Интервал должен быть не меньше 1 секунды",
"automations.rule.http_poll": "HTTP-опрос",
"automations.rule.http_poll.desc": "Срабатывает, когда последнее значение HTTP-источника соответствует условию.",
"automations.rule.http_poll.hint": "Сравнивает последнее извлечённое значение с вашим вводом. Что именно извлекается (тело или JSON-путь), задаётся в источнике-значении.",
"automations.rule.http_poll.value_source": "HTTP источник-значения",
"automations.rule.http_poll.operator": "Оператор",
"automations.rule.http_poll.value": "Значение",
"automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.no_source": "(нет источника)",
"automations.rule.http_poll.raw_body": "Тело ответа",
"automations.rule.http_poll.operator.equals": "Равно",
"automations.rule.http_poll.operator.equals.desc": "Точное совпадение строки.",
"automations.rule.http_poll.operator.not_equals": "Не равно",
"automations.rule.http_poll.operator.not_equals.desc": "Срабатывает, когда значение отличается.",
"automations.rule.http_poll.operator.contains": "Содержит",
"automations.rule.http_poll.operator.contains.desc": "Поиск подстроки.",
"automations.rule.http_poll.operator.regex": "Regex",
"automations.rule.http_poll.operator.regex.desc": "Регулярное выражение в стиле JavaScript.",
"automations.rule.http_poll.operator.gt": "Больше",
"automations.rule.http_poll.operator.gt.desc": "Числовое сравнение (>) — нужно числовое значение.",
"automations.rule.http_poll.operator.lt": "Меньше",
"automations.rule.http_poll.operator.lt.desc": "Числовое сравнение (<) — нужно числовое значение.",
"automations.rule.http_poll.operator.exists": "Существует",
"automations.rule.http_poll.operator.exists.desc": "Срабатывает, когда значение успешно извлечено (само значение игнорируется)."
}
+90 -6
View File
@@ -1390,17 +1390,17 @@
"color_strip.delete.referenced": "无法删除:此源正在被目标使用",
"color_strip.error.name_required": "请输入名称",
"color_strip.type": "类型:",
"color_strip.type.hint": "图片源从屏幕采集推导 LED 颜色。静态颜色用单一颜色填充所有 LED。渐变在所有 LED 上分布颜色渐变。颜色循环平滑循环用户定义的颜色列表。组合将多个源作为混合图层叠加。音频响应从实时音频输入驱动 LED。API 输入通过 REST 或 WebSocket 从外部客户端接收原始 LED 颜色。",
"color_strip.type.hint": "图片源从屏幕采集推导 LED 颜色。色用单一颜色填充所有 LED。渐变在所有 LED 上分布颜色渐变。颜色循环平滑循环用户定义的颜色列表。组合将多个源作为混合图层叠加。音频响应从实时音频输入驱动 LED。API 输入通过 REST 或 WebSocket 从外部客户端接收原始 LED 颜色。",
"color_strip.type.picture": "图片源",
"color_strip.type.picture.desc": "从屏幕捕获获取颜色",
"color_strip.type.picture_advanced": "多显示器",
"color_strip.type.picture_advanced.desc": "跨显示器的线条校准",
"color_strip.type.static": "静态颜色",
"color_strip.type.static.desc": "单色填充",
"color_strip.type.single_color": "色",
"color_strip.type.single_color.desc": "单色填充",
"color_strip.type.gradient": "渐变",
"color_strip.type.gradient.desc": "LED上的平滑颜色过渡",
"color_strip.static_color": "颜色:",
"color_strip.static_color.hint": "将发送到灯带上所有 LED 的纯色。",
"color_strip.single_color": "颜色:",
"color_strip.single_color.hint": "将发送到灯带上所有 LED 的纯色。",
"color_strip.gradient.preview": "渐变:",
"color_strip.gradient.preview.hint": "可视预览。点击下方标记轨道添加色标。拖动标记重新定位。",
"color_strip.gradient.stops": "色标:",
@@ -2636,5 +2636,89 @@
"pairing.success": "配对成功",
"pairing.not_ready": "设备未响应。请按下设备上的配对按钮后重试。",
"pairing.failed": "配对失败:{detail}",
"pairing.failed_prefix": "配对失败:"
"pairing.failed_prefix": "配对失败:",
"streams.group.http": "HTTP",
"http_endpoint.group.title": "HTTP 端点",
"http_endpoint.add": "添加 HTTP 端点",
"http_endpoint.edit": "编辑 HTTP 端点",
"http_endpoint.section.request": "请求",
"http_endpoint.section.headers": "请求头",
"http_endpoint.name": "名称:",
"http_endpoint.name.placeholder": "Plex 正在播放",
"http_endpoint.name.hint": "此端点的描述性名称",
"http_endpoint.url": "URL",
"http_endpoint.url.hint": "要轮询的完整 http(s) URL,允许使用本地地址。",
"http_endpoint.method": "方法:",
"http_endpoint.method.get.desc": "获取响应体。",
"http_endpoint.method.head.desc": "仅返回状态码,不返回响应体。适合健康检查。",
"http_endpoint.auth_token": "认证令牌(可选):",
"http_endpoint.auth_token.hint": "作为 'Authorization: Bearer <token>' 发送。在请求头中添加自定义 Authorization 可覆盖。",
"http_endpoint.auth_token.edit_hint": "留空以保留当前令牌",
"http_endpoint.auth_token.reveal": "显示 / 隐藏令牌",
"http_endpoint.auth.set": "认证",
"http_endpoint.timeout": "超时(秒):",
"http_endpoint.timeout.hint": "单次请求的最长等待秒数。",
"http_endpoint.test": "测试请求",
"http_endpoint.test.pending": "测试中…",
"http_endpoint.test.success": "成功",
"http_endpoint.test.failed": "失败",
"http_endpoint.test.body.json": "JSON 主体",
"http_endpoint.test.body.text": "响应主体",
"http_endpoint.headers": "自定义请求头:",
"http_endpoint.headers.hint": "可选的请求头(如 X-API-Key、Accept)。",
"http_endpoint.headers.add": "添加请求头",
"http_endpoint.headers.empty": "没有自定义请求头 — 将发送默认值。",
"http_endpoint.headers.name_placeholder": "请求头名称",
"http_endpoint.headers.value_placeholder": "值",
"http_endpoint.headers.count": "{n} 个请求头",
"http_endpoint.description": "描述(可选):",
"http_endpoint.created": "已创建 HTTP 端点",
"http_endpoint.updated": "已更新 HTTP 端点",
"http_endpoint.deleted": "已删除 HTTP 端点",
"http_endpoint.delete.confirm": "删除此 HTTP 端点?引用它的值源需要重新指向其他端点。",
"http_endpoint.error.name_required": "需要名称",
"http_endpoint.error.url_required": "需要 URL",
"http_endpoint.error.timeout_invalid": "超时必须为正数",
"http_endpoint.error.load": "加载 HTTP 端点失败",
"section.empty.http_endpoints": "暂无 HTTP 端点。点击 + 添加一个。",
"device.icon.entity.http_endpoint": "HTTP 端点",
"value_source.type.http": "HTTP 轮询",
"value_source.type.http.desc": "按固定间隔轮询 HTTP 端点,并将提取的值映射到 0–1。",
"value_source.http.endpoint": "HTTP 端点:",
"value_source.http.endpoint.hint": "从 HTTP 集成选项卡中选择已保存的端点。",
"value_source.http.json_path": "JSON 路径:",
"value_source.http.json_path.hint": "为空则使用原始响应体。使用点号/索引路径,例如 MediaContainer.Metadata[0].title",
"value_source.http.interval": "间隔(秒):",
"value_source.http.interval.hint": "轮询间隔(秒)。多个值源可以以不同间隔共享同一端点。",
"value_source.http.min_value": "最小值:",
"value_source.http.min_value.hint": "映射到输出 0.0 的原始值(用于归一化)。",
"value_source.http.max_value": "最大值:",
"value_source.http.max_value.hint": "映射到输出 1.0 的原始值(用于归一化)。",
"value_source.http.modulator.summary": "调制映射(可选)",
"value_source.http.modulator.hint": "仅当此源用于驱动亮度或颜色时使用。自动化规则会直接读取提取的原始值,并忽略这些设置。",
"value_source.http.endpoint_required": "需要 HTTP 端点",
"value_source.http.interval_invalid": "间隔至少为 1 秒",
"automations.rule.http_poll": "HTTP 轮询",
"automations.rule.http_poll.desc": "当 HTTP 值源的最新提取值匹配时激活。",
"automations.rule.http_poll.hint": "将最新的提取值与您的输入进行比较。提取的内容(原始响应体或 JSON 路径)由值源决定。",
"automations.rule.http_poll.value_source": "HTTP 值源",
"automations.rule.http_poll.operator": "运算符",
"automations.rule.http_poll.value": "值",
"automations.rule.http_poll.value.placeholder": "playing",
"automations.rule.http_poll.no_source": "(无来源)",
"automations.rule.http_poll.raw_body": "原始响应体",
"automations.rule.http_poll.operator.equals": "等于",
"automations.rule.http_poll.operator.equals.desc": "精确字符串匹配。",
"automations.rule.http_poll.operator.not_equals": "不等于",
"automations.rule.http_poll.operator.not_equals.desc": "当值不同时激活。",
"automations.rule.http_poll.operator.contains": "包含",
"automations.rule.http_poll.operator.contains.desc": "子字符串匹配。",
"automations.rule.http_poll.operator.regex": "正则",
"automations.rule.http_poll.operator.regex.desc": "JavaScript 风格的正则表达式。",
"automations.rule.http_poll.operator.gt": "大于",
"automations.rule.http_poll.operator.gt.desc": "数值比较 (>) — 需要数值输出。",
"automations.rule.http_poll.operator.lt": "小于",
"automations.rule.http_poll.operator.lt.desc": "数值比较 (<) — 需要数值输出。",
"automations.rule.http_poll.operator.exists": "存在",
"automations.rule.http_poll.operator.exists.desc": "只要成功提取出值就激活(忽略值本身)。"
}
+44
View File
@@ -201,6 +201,49 @@ class HomeAssistantRule(Rule):
)
@dataclass
class HTTPPollRule(Rule):
"""Activate based on a value extracted by an HTTP value source.
The extraction (URL, auth, json_path, cadence) lives on an
``HTTPValueSource``; this rule just references the value source and
compares its current raw value to ``value`` using ``operator``.
"""
rule_type: str = "http_poll"
value_source_id: str = "" # references an HTTPValueSource
operator: str = "equals" # equals | not_equals | contains | regex | gt | lt | exists
value: str = ""
def to_dict(self) -> dict:
d = super().to_dict()
d["value_source_id"] = self.value_source_id
d["operator"] = self.operator
d["value"] = self.value
return d
@classmethod
def from_dict(cls, data: dict) -> "HTTPPollRule":
# Accept legacy ``http_source_id`` + ``json_path`` payloads from any
# in-flight v1 data and ignore them — the new shape only needs the
# value_source_id. Log a warning so a dev DB that still has the old
# form on disk is visible: such a row will load with
# ``value_source_id=""`` and the rule will silently evaluate to
# False forever otherwise.
if "http_source_id" in data or "json_path" in data:
logger.warning(
"Migrating legacy http_poll rule (had keys: %s). "
"value_source_id is empty; re-point the rule at an HTTPValueSource "
"and re-save to clear this warning.",
sorted(k for k in ("http_source_id", "json_path") if k in data),
)
return cls(
value_source_id=data.get("value_source_id", ""),
operator=data.get("operator", "equals"),
value=data.get("value", ""),
)
_RULE_MAP: Dict[str, Type[Rule]] = {
"application": ApplicationRule,
"time_of_day": TimeOfDayRule,
@@ -210,6 +253,7 @@ _RULE_MAP: Dict[str, Type[Rule]] = {
"webhook": WebhookRule,
"startup": StartupRule,
"home_assistant": HomeAssistantRule,
"http_poll": HTTPPollRule,
# Legacy: "always" maps to StartupRule for migration
"always": StartupRule,
}
@@ -32,6 +32,15 @@ class BaseSqliteStore(Generic[T]):
self._items: Dict[str, T] = {}
self._deserializer = deserializer
self._lock = threading.RLock()
# Apply pending JSON-blob data migrations before loading rows so the
# in-memory cache reflects the canonical, post-migration shape. The
# runner is idempotent across stores — it consults a dedicated
# ``data_migrations`` audit table — so each store construction may
# invoke it without re-running already-applied migrations.
# Imported lazily to avoid a circular dependency at module load.
from ledgrab.storage.data_migrations import ALL_MIGRATIONS, MigrationRunner
MigrationRunner(db).run(ALL_MIGRATIONS)
self._load()
# -- I/O -----------------------------------------------------------------
@@ -7,7 +7,7 @@ calibration, color correction, smoothing, and FPS.
Current types:
PictureColorStripSource derives LED colors from a single PictureSource (simple 4-edge calibration)
AdvancedPictureColorStripSource line-based calibration across multiple PictureSources
StaticColorStripSource constant solid color fills all LEDs
SingleColorStripSource constant solid color fills all LEDs
GradientColorStripSource linear gradient across all LEDs from user-defined color stops
AudioColorStripSource audio-reactive visualization (spectrum, beat pulse, VU meter)
ApiInputColorStripSource receives raw LED colors from external clients via REST/WebSocket
@@ -366,8 +366,8 @@ class AdvancedPictureColorStripSource(ColorStripSource):
@dataclass
class StaticColorStripSource(ColorStripSource):
"""Color strip source that fills all LEDs with a single static color.
class SingleColorStripSource(ColorStripSource):
"""Color strip source that fills all LEDs with a single solid color.
No capture or processing -- the entire LED strip is set to one constant
RGB color. Useful for solid-color accents or as a placeholder while
@@ -384,11 +384,11 @@ class StaticColorStripSource(ColorStripSource):
return d
@classmethod
def from_dict(cls, data: dict) -> "StaticColorStripSource":
def from_dict(cls, data: dict) -> "SingleColorStripSource":
common = _parse_css_common(data)
return cls(
**common,
source_type="static",
source_type="single_color",
color=BindableColor.from_raw(data.get("color"), default=[255, 255, 255]),
animation=data.get("animation"),
)
@@ -412,7 +412,7 @@ class StaticColorStripSource(ColorStripSource):
return cls(
id=id,
name=name,
source_type="static",
source_type="single_color",
created_at=created_at,
updated_at=updated_at,
description=description,
@@ -1823,7 +1823,10 @@ class MathWaveColorStripSource(ColorStripSource):
_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
"picture": PictureColorStripSource,
"picture_advanced": AdvancedPictureColorStripSource,
"static": StaticColorStripSource,
"single_color": SingleColorStripSource,
# Legacy alias: pre-rename rows used "static". Kept so old DBs deserialize;
# ColorStripStore migrates the on-disk source_type to "single_color" on startup.
"static": SingleColorStripSource,
"gradient": GradientColorStripSource,
"effect": EffectColorStripSource,
"audio": AudioColorStripSource,

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