Compare commits

...

45 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
alexei.dolgolyov 17684afba1 docs: record review-fix pass in TODO.md
Marks the pre-merge code review as complete in TODO.md and lists each
finding alongside the commit that closed it. Branch is in shape to merge.
2026-05-16 11:06:51 +03:00
alexei.dolgolyov 0e3ae78de7 fix(devices): address pre-merge review findings
Closes the issues surfaced by the pre-merge code review of the
expand-device-support branch.

CRITICAL #2 -- update_device double-encrypts secrets in memory.
storage/device_store.py round-tripped through device.to_dict() which
encrypts hue_username / hue_client_key / ble_govee_key / nanoleaf_token
via _enc(), but Device.__init__ does not decrypt. The cached
self._items[device_id] thus held ciphertext where plaintext belonged,
breaking runtime auth for paired devices on any update -- even an
innocuous rename. Sourcing kwargs from vars(device) directly avoids
the round-trip. Regression tests cover Nanoleaf and Hue.

HIGH #3 -- secrets leaked in GET /api/v1/devices response.
DeviceResponse previously returned nanoleaf_token / hue_username /
hue_client_key in plaintext (decrypted server-side from storage),
defeating the encryption-at-rest. Replaced with nanoleaf_paired and
hue_paired booleans. ble_govee_key intentionally stays -- it's a
user-managed value pasted from a third-party tool, must remain visible
for edit. Frontend types.ts + the one nanoleaf_token reader updated to
the boolean.

HIGH #4 -- SSRF surface. validate_lan_host() added to net_classify.py;
called from each new driver's validate_device (DDP / Yeelight / WiZ /
LIFX / Govee / OPC / Nanoleaf) and from pair_device. Rejects literal
public IPs with a descriptive ValueError; non-IP hostnames pass
through (mDNS labels, bare hostnames). RFC6890 ranges (documentation,
former class E) are accepted as LAN-like since Python's
ipaddress.is_private treats them so -- correct policy for LedGrab.

HIGH #5 -- decrypt failure deletes the device row. _dec() now catches
the exception, logs an error, and returns "" instead of propagating.
Without the fix, a regenerated data/.secret_key would silently make
every Hue / Nanoleaf / BLE-Govee device disappear from the device list
on next startup. Regression test asserts a corrupt envelope leaves the
device hydratable.

HIGH #6 -- update_device route does not rstrip("/") for non-WLED.
Moved the trim before the WLED-specific scheme inference so every
device type gets consistent URL normalization between create and
update.

MEDIUM #7 -- Govee discovery port 4002 collision. Added a lazily-
initialized module-level asyncio.Lock that serializes concurrent
discover_govee_devices() calls; the previous behavior had the second
parallel scan silently return [] when the first still held port 4002.
Error message also clarified to mention another Govee tool.

MEDIUM #8 -- Nanoleaf discover() leaked browser tasks on cancellation.
Moved the browser cancel loop into the finally block so an interrupted
mDNS scan still tears them down.

MEDIUM #9 -- pair endpoint logged user-supplied URL with exc_info=True.
Added _sanitize_url_for_log() that strips userinfo + fragment, and
demoted the log from exc_info to type(exc).__name__ + str(exc) so a
hostile receiver's response body can't end up in the log file.

LOW -- Nanoleaf was the only client without a .port property. Added
one (returns NANOLEAF_PORT, fixed) for cross-driver symmetry.

LOW -- no end-to-end pair-then-create coverage. Added
TestPairThenCreateFlow.test_pair_then_create_persists_encrypted_token
which exercises the full path: POST /api/v1/devices/pair returned
fields, store.create_device, then asserts (a) in-memory plaintext,
(b) to_config() plaintext, (c) persisted ciphertext, (d) API response
strip + paired-boolean.

Tests: 1379 pass (was 1358 -- 21 new regression tests added).
ruff clean. TypeScript clean.
2026-05-16 11:06:10 +03:00
alexei.dolgolyov 7736bc6f58 fix(utils): commit url_scheme + net_classify dependencies
The DDP commit (8f1140a) added imports of infer_http_scheme into
api/routes/devices.py but missed bringing in the module itself --
url_scheme.py and its net_classify.py dependency were in the working
tree as untracked files only. On a clean checkout the FastAPI app
fails to start with ModuleNotFoundError.

Caught by the pre-merge code review. The 1358 passing tests only
worked because the local working tree happens to have the files.

This commit adds:
- ledgrab.utils.url_scheme: infer_http_scheme() for LAN-vs-public WLED
  URL scheme inference
- ledgrab.utils.net_classify: HostCategory enum + classify_ip() +
  is_blocked_for_ssrf() + is_local_for_http_default() + is_loopback().
  Single source of truth for IP categorisation used by safe_source
  (SSRF), url_scheme (LAN), and auth (loopback exemption).
- 107 unit tests (test_url_scheme.py + test_net_classify.py).

net_classify.is_blocked_for_ssrf is the primitive the device-driver
validate_device methods will use in the next commit to close HIGH #4
from the review.
2026-05-16 10:46:45 +03:00
alexei.dolgolyov 390d2b472c docs: mark expand-device-support branch ready for merge
Updates TODO.md to reflect the verification pass outcome:
- pixel_reduce extraction marked done (commit cc87fba)
- pre-merge verification pass marked complete: 1358 pytest tests pass,
  ruff and black clean (against pre-commit-pinned 24.10.0),
  npx tsc --noEmit clean, bundle compiles
- Mi-Light / MiBoxer explicitly marked WONTDO with rationale
  (esp8266_milight_hub firmware -> existing MQTT target is the better
  path for modern Mi-Light deployments)
- duplicate Nanoleaf bullet removed; Twinkly noted as deferred

No code changes -- this is purely a checkpoint for the merge review.
2026-05-16 04:18:33 +03:00
alexei.dolgolyov cc87fba0dd refactor(devices): extract _average_color to pixel_reduce
Six single-pixel LED clients (Yeelight, WiZ, LIFX, Govee, Nanoleaf, BLE)
had byte-for-byte identical local copies of the strip-averaging helper.
Consolidates into core/devices/pixel_reduce.average_color so the next
single-pixel driver can drop the local copy and so behavior changes
land in one place.

Hue is intentionally left out -- its Entertainment API addresses up to
seven lights individually rather than averaging.

Behavior is byte-identical (each call site re-imports under the same
underscore-prefixed local name). 1358 tests still pass.
2026-05-16 04:14:36 +03:00
alexei.dolgolyov 426484adf8 feat(devices): Nanoleaf OpenAPI target type + first pair-flow user
Adds support for Nanoleaf controllers (Light Panels / Canvas / Shapes /
Lines / Elements) via the documented HTTP REST API on port 16021.
First concrete consumer of the pair-UX scaffold from commit 2f31680 --
the abstraction is no longer speculative.

Backend:
- NanoleafClient is a single-pixel HTTP adapter: averages the strip to
  one RGB triple, converts to Nanoleaf's HSB scale (H 0-360 / S 0-100 /
  B 0-100), and PUTs to /api/v1/<token>/state with duration:0 so
  transitions are instant for ambilight. Brightness is clamped to >=1
  because Nanoleaf rejects brightness=0.
- pair_nanoleaf(host) implements the two-step handshake: POST
  /api/v1/new during the 30-second pairing window the controller opens
  after the user holds the power button for 5 s.
    200 -> {auth_token: "..."}
    403 -> raises PairingNotReady ("Hold the power button...")
    other / transport error -> RuntimeError wrapping the cause
- NanoleafDeviceProvider.pair_device returns {nanoleaf_token: ...}
  forwarded by POST /api/v1/devices/pair to the frontend for inclusion
  in the subsequent create payload.
- mDNS discovery via _nanoleafapi._tcp (and the v1 variant); failures
  yield [] rather than raising.
- Health check probes /api/v1 without a token (401/403 still proves
  the host is alive).
- NanoleafConfig has nanoleaf_token + nanoleaf_min_interval_ms
  (default 100 ms = ~10 Hz; HTTP overhead caps practical max ~20 Hz).
- Auth token encrypted at rest via _enc/_dec, matching Hue / BLE-Govee.
- 42 unit tests cover URL parsing, RGB->HSB conversion, pairing
  handshake (200 / 403 / 500 / missing-token / transport-error),
  state mutations, brightness clamp, set_power / set_brightness /
  set_color, connection lifecycle, provider validate / pair /
  discover / capabilities, and Device.to_config round-trip including
  the encrypted-token roundtrip via to_dict + from_dict.

Frontend:
- 'nanoleaf' in DEVICE_TYPE_KEYS (next to 'govee'), HEXAGON icon
  (deliberate departure from the smart-bulb lightbulb family --
  Nanoleaf is panels, not bulbs, and the brand identity is hexagonal).
- isNanoleafDevice predicate + per-type field show/hide.
- Pair flow integration: when the device type is Nanoleaf, the add-
  device modal retitles its submit button to "Pair Device" and
  intercepts the submit. handleAddDevice awaits
  runPairingFlow({deviceType: 'nanoleaf', url}), merges result.fields
  ({nanoleaf_token}) into the create body, then POSTs. On
  PairingCancelled the user stays on the modal silently.
- Settings modal exposes the rate-limit field and a read-only
  "Paired" indicator reusing the pair-modal success badge. The token
  itself is never rendered to the DOM and never sent on update --
  re-pairing requires delete + re-add.
- Per-type pairing instructions in en/ru/zh
  (device.nanoleaf.pair.instructions) that the scaffold's i18n lookup
  resolves automatically.
- Bundle: +6.4 KiB (pairing-flow.ts was tree-shaken before this
  commit; now both it and the Nanoleaf branches are baked in).

The pair-UX scaffold is now proven, not speculative. Tuya and Twinkly
can follow the same shape when their phases arrive.
2026-05-16 03:59:38 +03:00
alexei.dolgolyov 2f31680823 feat(devices): pairing-UX scaffold (Phase 2)
Lays the groundwork for device families that require a one-time
physical pairing action (Nanoleaf hold-power-button, Tuya local-key
extraction, Twinkly network-setup mode, Hue link-button). No driver
uses it yet -- Nanoleaf will be the first concrete consumer.

Phase 2 as originally written had three bullets; only this one was
genuinely missing work. The other two (generic NetworkDiscoveryService
fan-out, unified scan-network UI) were already solved at the route
level by the existing /api/v1/devices/discover handler running all
providers in parallel via asyncio.gather(return_exceptions=True).
Marked WONTDO in TODO.md with rationale.

Backend:
- LEDDeviceProvider gains an async pair_device(url) -> dict method.
  Default raises NotImplementedError so missing implementations on a
  requires_pairing provider fail loud at request time.
- New PairingNotReady exception, distinct from generic errors so the
  route handler can return 409 (user must perform the physical action,
  retry possible) instead of 500.
- POST /api/v1/devices/pair endpoint with PairDeviceRequest /
  PairDeviceResponse schemas. Status-code mapping:
    200 -> paired, fields returned for the subsequent create payload
    400 -> unknown device type, or type doesn't support pairing
    409 -> PairingNotReady (retryable from the UI)
    422 -> invalid URL / device configuration (ValueError)
    502 -> transport / network failure (other exceptions)
    500 -> provider returned a non-dict (defensive)
- 8 route tests register a stub provider and exercise every
  status-code path.

Frontend:
- New modals/pair-device.html with five state blocks (idle / pairing
  / not_ready / success / failed) toggled via data-pair-state, plus
  a 30-second SVG progress ring with monospace countdown.
- New features/pairing-flow.ts exposing
  runPairingFlow({deviceType, url, instructionsKey?}) ->
  Promise<{fields: Record<string, unknown>>. Wires the modal to the
  pair endpoint, maps response codes to UI states, AbortControllers
  in-flight fetches on cancel. Exports a PairingCancelled sentinel
  error class.
- Generic pairing.* i18n keys in en/ru/zh. Drivers will add their own
  device.<type>.pair.instructions key that overrides the default.

Design decisions (per frontend-design skill):
- Single SVG ring + centered countdown (HomeKit-style)
- Instructions stay visible during pairing, dimmed to 60% via :has()
- Success state held 450 ms before auto-dismiss
- Cancel-X in the footer; primary action lives in the state block
- prefers-reduced-motion disables pulse/fade/ring transitions

Note: the components.css diff includes a pre-existing MiniSelect block
from the user's parallel work; pairing-specific styles are the second
hunk (lines ~1628+).
2026-05-16 03:26:53 +03:00
alexei.dolgolyov 31c6c3abb2 feat(devices): Open Pixel Control (OPC) target type
Adds support for Open Pixel Control receivers (Fadecandy boards,
xLights/Falcon endpoints, OPC bridges, art-installation controllers,
hobbyist LED driver software). OPC is a tiny TCP protocol on port
7890 with a 4-byte header [channel][cmd][len_hi][len_lo] + RGB body.

Backend:
- OPCClient opens one persistent TCP connection and streams frames as
  header+body byte pairs. Channel 0 broadcasts to every output on the
  OPC server; channels 1-255 address a specific channel on multi-output
  servers (Fadecandy with multiple Open Pixel chains).
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
  The fast path skips the async drain so the OS write-buffer flushes
  on its own schedule -- exactly what ambilight streaming wants.
- Brightness applies client-side before the frame is sent (OPC has no
  reply channel for hardware-side brightness).
- Health check opens a TCP connection and closes it.
- OPCConfig joins the typed config union; storage gains an opc_channel
  field; full to_dict/from_dict/to_config wiring.
- 36 unit tests cover URL parsing, header construction, send_pixels
  emitting header+body in order, brightness application, list and
  flat-array input shapes, drain behavior, connection lifecycle,
  provider validate/discover/capabilities, Device.to_config round-trip.

Frontend:
- 'opc' in DEVICE_TYPE_KEYS (next to 'ddp'), paper-plane icon -- same
  as DDP since both are open pixel-streaming protocols.
- isOpcDevice predicate + per-type field show/hide.
- Optional channel number input (default 0 = broadcast) with hint copy
  explaining the channel semantics.
- Locale strings in en/ru/zh.

No native discovery (OPC has no discovery protocol); users supply
the receiver IP manually.
2026-05-16 03:02:41 +03:00
alexei.dolgolyov 887131d4af feat(devices): Govee LAN target type
Adds support for Govee Wi-Fi smart bulbs and ambient-lighting kits via
their LAN API (opened in 2023). Discovery is multicast UDP on
239.255.255.250:4001; control commands go unicast to the device's port
4003; responses arrive on port 4002.

Each device requires "LAN Control" toggled ON in the Govee Home app
(Device -> settings -> LAN Control). Devices with LAN Control disabled
silently fail to appear in discovery and won't respond to commands; the
UI hint copy reminds users.

Backend:
- GoveeClient is a single-pixel UDP adapter: averages the strip to one
  RGB triple and pushes a 'colorwc' command with colorTemInKelvin=0 to
  select pure RGB mode (non-zero kelvin would switch the bulb to CCT
  mode and ignore the RGB values).
- Brightness folds into the RGB scaling so we burn one packet per
  frame instead of two.
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
  Default rate gate 50 ms (~20 Hz); UDP fire-and-forget tolerates it.
- Multicast discovery: scan request to 239.255.255.250:4001, listen on
  port 4002, parse the inner data dict for IP + device-id + SKU +
  firmware version. Degrades to [] when port 4002 is already bound or
  network is unavailable.
- Health check sends devStatus and waits 1.5s for any reply; the error
  message points at the LAN-Control toggle since that's the #1 root
  cause of silent failures.
- GoveeConfig joins the typed config union; storage gains
  govee_min_interval_ms; full to_dict/from_dict/to_config wiring.
- 40 unit tests cover URL parsing, scan-reply parsing (rejecting
  non-scan commands and malformed JSON), payload builders (colorwc
  with colorTemInKelvin=0, brightness clamping, power as 1/0 not
  true/false), strip averaging, rate limiting, fast-send hot path,
  provider validate/discover/health, Device.to_config round-trip.

Frontend:
- 'govee' in DEVICE_TYPE_KEYS (next to 'lifx'), lightbulb icon
  (deliberate smart-bulb family grouping).
- isGoveeDevice predicate + per-type field show/hide.
- Rate-limit number input (default 50 ms).
- URL hint copy explicitly instructs users to enable LAN Control in
  the Govee Home app -- the #1 source of "why isn't my Govee
  responding?" support churn.
- Locale strings in en/ru/zh.
2026-05-16 02:47:15 +03:00
alexei.dolgolyov 8f9d490063 feat(devices): LIFX LAN target type
Adds support for LIFX smart bulbs and lightstrips that speak the LIFX
binary UDP protocol on port 56700, with broadcast LAN discovery via the
standard GetService/StateService probe.

Backend:
- LIFXClient is a single-pixel UDP adapter: averages the strip to one
  RGB triple, converts to LIFX HSBK (16-bit hue/saturation/brightness +
  kelvin), and pushes a tagged SetColor packet so all bulbs on the
  subnet act on it. Brightness folds into the HSBK brightness channel.
- Hand-rolled packet builder: 36-byte LIFX header (frame +
  frame-address + protocol-header) + variable-length payload. Source
  ID 'LGGR' identifies LedGrab in protocol logs.
- supports_fast_send=True with a synchronous send_pixels_fast hot path
  -- UDP costs nothing, so the default rate gate is 50 ms (~20 Hz) to
  match LIFX's documented <=20 cmd/sec recommendation.
- Broadcast discovery sends GetService and parses StateService replies
  back into IP + MAC + service-port triples. Broadcast failures yield
  [] rather than raising.
- Health check sends GetService and waits 1.5s for any reply on a
  one-shot UDP socket.
- LIFXConfig joins the typed config union; Device storage gains a
  lifx_min_interval_ms field; full to_dict/from_dict/to_config wiring.
- 47 unit tests cover URL parsing, RGB->HSBK conversion (red/green/
  blue/white/black/clamping), packet construction (size, msg type,
  tagged flag, target MAC, sequence byte), SetColor and SetPower
  payload layouts, StateService reply parsing (including rejection
  of wrong msg types and runt payloads), strip averaging, rate
  limiting, fast-send hot path, provider validate/discover/health,
  and Device.to_config round-trip.

Frontend:
- 'lifx' in DEVICE_TYPE_KEYS (next to 'wiz'), lightbulb icon
  (deliberate smart-bulb family grouping with Hue + Yeelight + WiZ).
- isLifxDevice predicate + per-type field show/hide in create and
  settings modals.
- Rate-limit number input (default 50 ms) in both modals with hint
  text referencing LIFX's documented <=20 cmd/sec ceiling.
- Locale strings in en/ru/zh.

LIFX bulbs are reachable from the existing "Scan network" button -- no
new discovery UI affordance was needed. No brightness_control capability
exposed; LIFX brightness is folded into the HSBK on the wire.
2026-05-16 02:30:30 +03:00
alexei.dolgolyov ede627b4ac feat(devices): WiZ Connected LAN target type
Adds support for WiZ Connected (Philips' budget-tier) smart bulbs that
accept JSON commands as UDP datagrams on port 38899 with broadcast LAN
discovery on 255.255.255.255:38899.

Backend:
- WiZClient is a single-pixel UDP adapter: averages the incoming strip
  to one RGB triple and pushes it via setPilot with r/g/b params.
  Brightness folds into the RGB scaling so we burn one packet per frame
  instead of two.
- UDP fire-and-forget tolerates high update rates with no ack overhead,
  so the default rate gate is 50 ms (~20 Hz) -- 10x faster than Yeelight.
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
- Broadcast discovery sends the standard registration envelope; bulb
  replies are parsed for IP+MAC and surfaced as DiscoveredDevice
  entries. Broadcast failures (no network, firewall) yield [] rather
  than raising.
- Health check sends getPilot and waits 1.5s for any reply on a
  one-shot UDP socket.
- WiZConfig joins the typed config union; Device storage gains a
  wiz_min_interval_ms field; full to_dict/from_dict/to_config wiring.
- 36 unit tests cover URL parsing, MAC extraction, strip averaging,
  rate limiting, fast-send hot path, provider validate/discover/health,
  and Device.to_config round-trip.

Frontend:
- 'wiz' in DEVICE_TYPE_KEYS (next to 'yeelight'), lightbulb icon
  (deliberate smart-bulb family grouping with Hue + Yeelight).
- isWizDevice predicate + per-type field show/hide in create and
  settings modals.
- Rate-limit number input (default 50 ms) in both modals with hint
  text noting the UDP fire-and-forget characteristic.
- Locale strings in en/ru/zh.

WiZ bulbs are reachable from the existing "Scan network" button -- no
new discovery UI affordance was needed.
2026-05-16 02:12:01 +03:00
alexei.dolgolyov 4b65005823 feat(devices): Yeelight LAN target type
Adds support for Xiaomi/Yeelight smart bulbs and lightstrips that speak
the bulb-vendor's JSON-RPC protocol over TCP port 55443 with SSDP-style
LAN discovery on 239.255.255.250:1982.

Backend:
- YeelightClient is a single-pixel adapter: it averages the incoming
  strip down to one RGB triple, packs it into the 24-bit color int the
  bulb expects, and pushes it via set_rgb with sudden+0ms effect.
- Brightness folds into the RGB scaling on the wire so we burn one
  command per frame instead of two.
- A configurable client-side rate gate (yeelight_min_interval_ms, default
  500) keeps us under the bulb's ~1 cmd/sec cap. Frames that arrive
  inside the gate no-op without TX. Music mode (~60 Hz via reverse-TCP)
  is deferred -- the MVP caps at ~2 Hz and that's fine for a strip-to-
  single-pixel averaging device.
- SSDP discovery scans 239.255.255.250:1982 with the bulb-specific
  ST: wifi_bulb header; replies are parsed into DiscoveredDevice
  entries. Multicast failures (no network, firewall) yield [] rather
  than raising -- discovery is best-effort.
- Health check opens a TCP socket to the bulb and closes it.
- YeelightConfig joins the typed config union; Device storage gains a
  yeelight_min_interval_ms field; full to_dict/from_dict/to_config wiring.
- 34 unit tests cover URL parsing, RGB packing, strip averaging, rate
  limiting, SSDP response parsing, provider validate/discover/health,
  and Device.to_config round-trip.

Frontend:
- 'yeelight' in DEVICE_TYPE_KEYS (next to 'hue'), lightbulb icon
  (intentional family-grouping signal with Hue).
- isYeelightDevice predicate + per-type field show/hide in create and
  settings modals.
- Rate-limit number input (default 500 ms) in both modals with hint
  text explaining the trade-off.
- Locale strings in en/ru/zh.
- Drive-by: types.ts DeviceType union backfilled with 'ddp' and 'ble'
  for type-safety consistency.

Yeelight bulbs are now reachable from the existing "Scan network"
button -- no new discovery UI affordance was needed.
2026-05-16 01:44:13 +03:00
alexei.dolgolyov 8f1140abad feat(devices): standalone DDP target type
Promotes the existing DDP packet layer (previously WLED-internal) to a
first-class device type so any DDP-speaking receiver (Pixelblaze,
ESPixelStick, xLights/Falcon endpoints, generic firmware) can be driven
directly without WLED in the path.

Backend:
- New DDPLEDClient wraps the DDPClient transport as a proper LEDClient
  with supports_fast_send=True (synchronous UDP push on the hot loop).
- New DDPDeviceProvider — no native discovery, manual LED count,
  capabilities = {manual_led_count, health_check}.
- DDPConfig joins the typed config union; Device storage gains
  ddp_port / ddp_destination_id / ddp_color_order fields with safe
  defaults (0/1/1 -> port 4048, destination 1=display, RGB byte order).
- URL scheme: ddp://host[:port] or bare host[:port] (default 4048).
- Health check resolves the host via async DNS; UDP has no reply
  channel so reachability is best-effort by design.
- 29 new tests in test_ddp_led_client.py cover URL parsing, packet
  hot path (brightness, list/numpy input shapes, fast vs async send),
  provider validate/discover/capabilities, config round-trip via
  Device.to_config() and to_dict/from_dict.

Frontend:
- 'ddp' in DEVICE_TYPE_KEYS (next to 'dmx'), paper-plane icon.
- isDdpDevice predicate + per-type field show/hide in the create &
  settings modals.
- Color-order picker uses IconSelect (project rule bans plain select).
- Locale strings added in en/ru/zh.

Note: this commit also carries two pre-existing in-flight hunks that
were intermixed in the same files and could not be split out
non-interactively:
- api/routes/devices.py: URL-scheme inference for bare WLED hosts,
  safer error messages, exception-isolated parallel discovery.
- storage/device_store.py: secret_box helpers + at-rest encryption of
  Hue / BLE-Govee / MQTT credentials.
Both are independent of DDP and intentional per the user.
2026-05-16 01:26:45 +03:00
alexei.dolgolyov 337984c618 feat(color-strips): in-editor live preview for all viable source types
Extend the editor's Preview button to render unsaved form values for every
CSS source type that can be previewed without external calibration. New
types now supported transiently: audio, math_wave, weather, game_event,
api_input, mapped, composite, processed.

Backend (preview WebSocket):
- Dispatch in _create_stream by source_type, injecting the dependencies each
  stream needs (audio managers, weather manager, value stream manager, CSPT
  store via public get_cspt_store, color strip stream manager).
- Roll back clock + stream resources if start() fails so failed previews
  don't leak refs.
- On source_type change mid-preview, drop the rebuilt-stream reference if
  rebuild fails and close the WS rather than poll a stopped stream.

Stream lifecycle fixes flushed out by the new preview paths:
- MappedColorStripStream and ProcessedColorStripStream now stamp a per-
  instance UUID into the sub-stream consumer_id so concurrent consumers
  (multiple preview WS connections) don't collide in the CSM registry.
- ProcessedColorStripStream.update_source now re-acquires the input stream
  when input_source_id changes (previously silently kept the old input).

Frontend:
- Expand _PREVIEW_TYPES; route non-quirky types through a new exported
  getCSSEditorPreviewPayload helper that reuses the existing per-type
  handler registry.
- For picture / picture_advanced / key_colors (which depend on calibration
  or rectangles edited elsewhere), show a clearer "save the source first"
  message instead of the generic "unsupported" toast.
2026-05-16 00:40:26 +03:00
alexei.dolgolyov 530316c2c3 feat(mqtt): multi-broker MQTT + Zigbee2MQTT light target
- New Z2MLightOutputTarget storage, processor, editor and routes for
  Zigbee2MQTT light entities (shares the HA-Light editor UI via the new
  light-target-editor module)
- Replace global MQTTService/MQTTConfig with per-source MQTTManager +
  MQTTRuntime; thread mqtt_source_id through Z2M targets, DIY MQTT
  devices, and the automation engine
- Migrate legacy single-broker YAML/env config to a "Default Broker"
  MQTTSource on startup (core/mqtt/legacy_migration.py) and drop the
  obsolete core/mqtt/mqtt_service.py
- Refresh /api/v1/system integration status to surface every MQTT source
- Extract shared light-target editor and refactor OutputTargetStore +
  output_targets routes around typed factories / auto-registry
- Modal CSS polish, locale strings, and storage/bindable test coverage
2026-05-12 18:06:09 +03:00
alexei.dolgolyov 6e4c1b6642 perf(wled): cache per-frame max-pixel for brightness threshold
Cache the np.max(frame) result keyed on frame identity. The
min_brightness_threshold check ran a full reduction over the LED
array on every loop iteration even when the frame reference was
unchanged — at 60 fps with multiple targets this added up to
hundreds of redundant reductions per second.
2026-05-12 15:06:34 +03:00
alexei.dolgolyov ee4fa81376 perf(processing): event-driven frame hand-off and scheduling fixes
- LiveStream: add frame_id counter + Condition with wait_for_new_frame()
  helper. Producers (ScreenCaptureLiveStream, ProcessedLiveStream,
  StaticImageLiveStream, VideoCaptureLiveStream) now signal_new_frame()
  on each new frame; consumers (PictureColorStripStream, ProcessedLive
  Stream) wait on the event with frame_time as a safety timeout
  instead of polling + sleeping. Cuts glass-to-LED latency at matched
  FPS by up to one frame_time.
- ProcessedLiveStream ring buffer: 3 -> 5 slots. The previous "max 2
  frames in flight" assumption ignored the multi-consumer case where
  several PictureColorStripStream/HA-target threads can hold the same
  _latest_frame reference while we wrap. 5 slots gives ~83 ms of
  consumer-read margin at 60 FPS.
- PictureColorStripStream advanced mode: reuse the already-fetched
  primary frame instead of re-acquiring its lock from _live_streams.
- _blend_u16: use cv2.addWeighted (single SIMD-fused pass) when cv2
  is available; numpy fallback unchanged. Output verified bit-equal
  to the existing 6-pass implementation.
- FrameLimiter.wait: drop the 1 ms minimum-sleep floor. Over-budget
  loops no longer add an extra ms per iteration; the cap on achievable
  rate (~750 fps) is removed.
2026-05-12 15:06:11 +03:00
alexei.dolgolyov f184ef0afb perf(capture): vectorize hot paths and fix engine bugs
- WGC: replace per-frame ~30 MB BGRA->RGB fancy-index allocation with
  cv2.cvtColor into a 3-slot pre-allocated RGB pool. Use gc.collect(0)
  on cleanup instead of full GC to avoid multi-hundred-ms stalls.
- MSS: switch from screenshot.rgb (pure-Python BGRA->RGB rebuild) to
  screenshot.raw + cv2.cvtColor into a pooled buffer. Add cheap 256-byte
  hash-based change detection so idle frames return None — matches
  DXcam/BetterCam semantics.
- DXcam/BetterCam: fix silent factory leak — Python name-mangling
  rewrote self._dxcam.__factory to _DXcamCaptureStream__factory inside
  the class body, so cleanup never reached the real attribute. Use
  getattr with string literal to bypass mangling.
- calculate_dominant_color: replace np.random.choice(replace=False)
  (full sort) with np.random.randint, and np.unique(axis=0) (lexsort)
  with packed-RGB np.bincount. ~10x faster on dominant mode.
- calibration._map_edge_average: switch cached scratch buffers from
  float64 to float32. Halves memory bandwidth on the dominant reduction
  path; range-safe up to 8K screens.
- All engines: per-frame DEBUG logs use structlog kwarg style instead
  of f-strings to avoid per-frame string allocation.
2026-05-12 15:05:52 +03:00
alexei.dolgolyov ad84b60ae4 fix(ha-light): apply brightness_scale once and respect boost multipliers
`_send_entity_color` was multiplying the per-mapping `brightness_scale`
into the brightness payload twice when the effective scale was below 1,
yielding a quartered output for a configured half-scale. Conversely,
when the value-stream multiplier exceeded 1.0 with a default scale,
the entire scaling step was skipped and the boost was lost.

Compute brightness as `clamp(max(r,g,b) * bs * vs, 0, 255)` once and
ship it directly, with regression tests pinning the half-scale, boost,
and 255-clamp cases.
2026-05-11 01:42:02 +03:00
alexei.dolgolyov cdf7d94652 feat(ui): expand card icon picker (44 -> 120 icons, +5 categories)
Add 76 new icons to the custom card-icon picker and introduce five new
categories: weather, nature, controls, status, office. Existing icon ids
are unchanged so persisted card icons keep resolving.

- icon-paths.ts: +36 Lucide path constants (weather, nature, room,
  office, media, hardware, lighting variants)
- device-icons.ts: extend IconCategory union and CATEGORIES; add
  registry entries with labels + search aliases
- en/ru/zh locales: 5 new category labels + 76 per-icon labels each
  (126 device.icon keys per locale, fully aligned)

Tabs scroll horizontally via existing overflow-x; no migration needed
(picker reads/writes ids by value, missing ids fall back to inheritance).
2026-05-11 01:38:40 +03:00
217 changed files with 26803 additions and 2739 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.
+451 -1
View File
@@ -1,5 +1,299 @@
# 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
references an `MQTTSource.id`; `MQTTManager` is the only entry point.
`MQTTManager` + `MQTTRuntime` already exist — the job is to migrate every
caller off the legacy path, then delete it.
### Phase 1 — `mqtt_source_id` on Z2M target
- [x] Field on `Z2MLightOutputTarget` storage dataclass (+ to/from_dict)
- [x] Field on Z2M create/update/response schemas
- [x] Validate referenced `MQTTSource` exists at create/update
- [x] Thread through `output_target_store.create_z2m_light_target` + update
- [x] Thread through `ProcessorManager.add_z2m_light_target`
- [x] Thread through `Z2MLightTargetProcessor` constructor
### Phase 2 — Z2M processor uses `MQTTManager`
- [x] Replace `_mqtt_service` with `_mqtt_runtime` acquired from manager
- [x] `start()` acquire / `stop()` release
- [x] `_publish_payload` → `self._mqtt_runtime.publish(...)`
- [x] `turn_off_lights` borrow-pattern via manager (mirror HA-light)
- [x] Add `mqtt_manager` to `ProcessorDependencies` / `TargetContext`
### Phase 3 — Z2M editor UI
- [x] Add MQTT broker `EntitySelect` in Routing
- [x] Reuse `mqttSourcesCache`
- [x] Wire `mqtt_source_id` into edit-load + save payload + validation
### Phase 4 — DIY MQTT device (`MQTTLEDClient`)
- [x] `mqtt_source_id` field on `Device` storage
- [x] Field on `device_config.MQTTConfig`
- [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()`
- [x] Provider threads `mqtt_manager` via `ProviderDeps`
- [ ] Device editor: MQTT source picker shown for `device_type=mqtt` *(UI still
pending — backend accepts the field, but the device-create form doesn't
expose it yet)*
### Phase 5 — `AutomationEngine`
- [x] Drop `mqtt_service` ctor parameter
- [x] Drop legacy fallback in `_evaluate_mqtt` (rule must reference a source)
### Phase 6 — `api/routes/system.py`
- [x] Replace integration status with `mqtt_manager.get_all_sources_status()`
- [ ] Update frontend dashboard payload (MQTT widget now expects a list of
sources instead of a single `enabled`/`connected` pair — surface in UI)
### Phase 7 — Startup migration
- [x] Seed a "Default Broker" `MQTTSource` if legacy YAML / env had a
broker configured and the store is empty (`core.mqtt.legacy_migration`)
- [x] Deprecation warning logged on migration; YAML/env no longer read after
### Phase 8 — Remove legacy
- [x] Delete `core/mqtt/mqtt_service.py`
- [x] Delete `set_mqtt_service` / `get_mqtt_service` (mqtt_client.py)
- [x] Remove `MQTTService` from `main.py`
- [x] Remove `MQTTConfig` + `resolve_mqtt_password` from `config.py`
- [x] Remove `mqtt: MQTTConfig` from `Config` (with `extra="ignore"` so legacy
YAML still loads)
### Phase 9 — Verification
- [x] `pytest tests/ --no-cov -q` clean (973 passing; removed obsolete
`test_default_mqtt_disabled`)
- [x] `ruff check src/` clean
- [x] `tsc --noEmit` + `npm run build`
- [ ] Smoke test: Z2M target on a configured MQTT Source publishes to broker
(manual)
## Refactor: typed output-target factories + auto-registry
Replaced `target_type` string elif chains in `OutputTargetStore` and
`OutputTarget.from_dict` with: (1) `__init_subclass__` registry for
deserialization, (2) per-type typed `create_*_target` /
`update_*_target` methods called directly from the route layer's
`match data:` dispatch. API contract unchanged, no DB migration.
### Phase 1 — Registry on `OutputTarget`
- [x] Added `_registry` + `_type_key` ClassVars + `__init_subclass__(*, type_key)`
- [x] Rewrote `OutputTarget.from_dict` to dispatch via registry
- [x] Declared `type_key="led"` / `"ha_light"` / `"z2m_light"` on the three subclasses
### Phase 2 — Typed `create_*_target` methods
- [x] Extracted `_resolve_brightness`, `_resolve_transition`, `_check_unique_name`,
`_new_id_and_now`, `_finalize` helpers on the store
- [x] Added `create_wled_target` / `create_ha_light_target` / `create_z2m_light_target`
with per-type defaults (transition 0.5/0.3, update_rate 2.0/5.0) baked into
their signatures
### Phase 3 — Typed `update_*_target` methods
- [x] Added `update_wled_target` / `update_ha_light_target` / `update_z2m_light_target`
with `_begin_update` / `_commit_update` helpers
- [x] Each typed update method validates the target's class before mutating
### Phase 4 — Route migration
- [x] `create_target` route uses `match data:` to call typed store methods —
no more `getattr(data, "x", default)` pyramid
- [x] `update_target` route uses `match data:` and computes `settings_changed` /
`css_changed` / `brightness_changed` per-arm from typed fields
- [x] Helpers `_build_ha_mappings`, `_build_z2m_mappings`,
`_validate_device_exists`, `_resolve_effective_color_vs_id` extracted
### Phase 5 — Decision: keep both shims
After grepping for callers, `src/ledgrab/core/scenes/scene_activator.py:90`
calls `target_store.update_target(target_id, **changed)` with a dynamically
built dict — it legitimately doesn't know the target's type at the call site.
The shims are now ~30-line dispatchers that route to typed methods (no more
inline construction elif chains), so the original anti-pattern is gone while
the generic API remains available for "don't-know-the-type" callers like the
scene activator. Tests continue to use the shorthand `create_target("A", "led")`
form without churn.
### Phase 6 — Verify
- [x] `ruff check` clean on all modified files
- [x] `py -3.13 -m pytest tests/ --no-cov -q` — 974 passed (was 974 before)
- [ ] Manual smoke test in UI: create/edit/delete each of the three target types
## Custom card icons — extend to all card types
Migrate the existing icon-plate work (devices, LED targets, HA-light targets)
@@ -131,7 +425,7 @@ Branch: `feat/device-event-notifications`. Default ON.
permission row + Test-notification button.
- [x] i18n: `settings.notifications.*` and `notifications.*` keys in en/ru/zh.
### Verification
### Verification (notifications)
- [x] `npx tsc --noEmit` clean, `npm run build` produces 2.5 MB bundle.
- [x] `ruff check src/ tests/` clean. 899/899 pytest pass.
@@ -530,3 +824,159 @@ Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated un
- [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR)
- [x] Phase 4 — migrate `tests/test_group_device.py` to `GroupConfig`/`ProviderDeps`; remove legacy `GroupLEDClient` init path; 47-test config suite with 100% coverage on `device_config.py`
- [ ] Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in `api/schemas/devices.py`; scope frontend POST/PATCH payloads by `device_type`
## Expand device support (Phase 1: open protocols)
Branch: `feat/expand-device-support`.
Goal: maximize the universe of LED controllers LedGrab can drive by adding aggregator + open-protocol providers in roughly-this order. Each driver follows the established `LEDDeviceProvider` + `*Config` + tests pattern.
### Phase 1.1 — Standalone DDP target ✅ shipped (commit `8f1140a`)
DDP packet layer (previously WLED-internal) promoted to a first-class device
type. Pixelblaze, ESPixelStick, xLights/Falcon endpoints, and generic DDP
receivers are now drivable directly without WLED in the path.
### Phase 1.2 — Yeelight LAN
Xiaomi/Yeelight bulbs, port 55443 TCP JSON. Direct protocol (no
`python-yeelight` dependency — implementation is ~200 lines).
- [x] `YeelightConfig` dataclass with `yeelight_min_interval_ms` rate limit
- [x] `YeelightClient` in `core/devices/yeelight_client.py` — TCP JSON-RPC,
averaging single-pixel adapter, client-side rate gate
- [x] SSDP-style discovery (Yeelight's variant on `239.255.255.250:1982`)
- [x] `YeelightDeviceProvider` with validate/health/discover
- [x] Storage + API schemas + route handler wiring
- [x] 34 unit tests (URL parsing, RGB packing, averaging, rate limit, SSDP
parsing, provider validate/discover, Device.to_config round-trip)
- [ ] Frontend: Yeelight in device-type picker + edit form (spawned to a
`frontend-design` subagent)
- [ ] Locale strings (en/ru/zh)
- [ ] Music mode (~60 Hz updates via reverse-TCP) — follow-up, current
MVP caps at ~2 Hz via the client-side rate gate
### Phase 1.3 — WiZ Connected
Philips' UDP-local budget tier. Port 38899 JSON UDP.
- [x] `WiZConfig` + `WiZClient` + `WiZDeviceProvider`
- [x] UDP broadcast discovery on 255.255.255.255:38899 with the standard
`registration` envelope; replies parsed for IP+MAC.
- [x] Sync `send_pixels_fast` for the hot loop (UDP is fire-and-forget,
no async needed). 50 ms default min interval → ~20 Hz cap.
- [x] Health check sends `getPilot` and waits for any reply.
- [x] Storage + API schemas + route handler wiring
- [x] 36 unit tests
- [ ] Frontend: WiZ in device-type picker + edit form
- [ ] Locale strings (en/ru/zh)
### Phase 2 — Unified discovery + pairing UX layer
After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen for replies, present a list". Factor that out into a generic discovery scaffold + a "first-run pairing" UX component before adding Tuya/Govee/etc., which each need a one-time pairing dance.
- [WONTDO] Generic `NetworkDiscoveryService` — the existing
`/api/v1/devices/discover` route already runs all providers in parallel
via `asyncio.gather(return_exceptions=True)`. Extracting it would not
unlock anything; revisit only if discovery cadence/dedup becomes a
real complaint.
- [WONTDO] Unified scan UI — already exists; one "Scan network" button
triggers the cross-provider fan-out.
- [x] **Reusable pair-device scaffold** (the actually-needed piece).
Backend: `LEDDeviceProvider.pair_device(url)` abstract method with
`PairingNotReady` sentinel; `POST /api/v1/devices/pair` endpoint
with status-code mapping (200/400/409/422/502); 8 route tests
covering every outcome. Frontend: `templates/modals/pair-device.html`
five-state modal (idle / pairing / not-ready / success / failed)
with a 30-second SVG progress ring; reusable
`static/js/features/pairing-flow.ts` exposing
`runPairingFlow({deviceType, url}) → Promise<{fields}>` with
`PairingCancelled` sentinel; locale strings in en/ru/zh. No driver
uses it yet — Nanoleaf will be the first concrete consumer.
### Phase 3 — Big aggregator unlocks
- [ ] ESPHome native API (`aioesphomeapi`)
- [ ] Tuya Local (`tinytuya`) — biggest single market unlock; needs the pairing UX from Phase 2
- [ ] Matter over IP (forward-looking)
- [ ] Hyperion JSON downstream
### Phase 4 — Major consumer brands
- [x] **LIFX LAN** — UDP binary protocol on port 56700; RGB→HSBK 16-bit
conversion; broadcast discovery via GetService/StateService probe;
47 unit tests. Single-pixel adapter shape, identical to WiZ
structurally. Frontend wired via subagent.
- [x] **Govee LAN API** — UDP JSON on port 4003 (control) + 4002
(responses) + 4001 (multicast discovery on 239.255.255.250).
Single-pixel `colorwc` command with `colorTemInKelvin=0` for RGB
mode. **Per-device "LAN Control" toggle required in Govee Home
app.** 40 unit tests. Frontend wired via subagent.
- [x] **Nanoleaf OpenAPI** — Light Panels / Canvas / Shapes / Lines /
Elements via HTTP REST on port 16021. **First concrete user of
the pairing-UX scaffold from Phase 2.** mDNS discovery via
`_nanoleafapi._tcp`. Single-pixel adapter (averaged strip → HSB
`PUT /state`). Auth token encrypted at rest via `_enc`/`_dec`.
42 unit tests covering URL parsing, RGB→HSB conversion, pairing
handshake (200/403/500/missing-token/transport-error), state
mutations, brightness clamping, Device.to_config round-trip
including encrypted-token roundtrip.
- [ ] Twinkly — multi-pixel + login flow; deferred
- [WONTDO] Mi-Light / MiBoxer UDP gateway — the recommended path for
modern Mi-Light deployments is `esp8266_milight_hub` firmware → MQTT,
which LedGrab already supports through the existing MQTT device target
(commit `530316c`). Native V6 driver would be ~400 lines + finicky
session protocol + custom 1-byte hue table; the marginal benefit over
the MQTT path is small. Revisit if a user complaint surfaces.
### Phase 5 — Open pixel protocols (cheap completionism)
- [x] **OPC (Open Pixel Control)** — TCP, port 7890, 4-byte header
`[channel][cmd][len_hi][len_lo]` + RGB body. Channel 0 broadcasts.
Single-pixel-strip protocol, no discovery, no pairing. 36 unit
tests. Fadecandy + xLights + hobbyist receivers reachable.
- [ ] TPM2.net
### Phase 6 — PC gaming RGB completion
- [ ] Corsair iCUE SDK
- [ ] Logitech LIGHTSYNC
- [ ] ASUS Aura SDK
### Phase 7 — Proprietary USB HID ambient kits
- [ ] Generic HID-ambient framework + VID/PID registry
- [ ] First reverse-engineered target (probably Govee Immersion / DreamView)
### Cleanup + verification
- [x] **`_average_color` extraction** (commit `cc87fba`). Six identical
copies (Yeelight / WiZ / LIFX / Govee / Nanoleaf / BLE) collapsed
into `core/devices/pixel_reduce.average_color`. Net -76 lines.
Hue is out by design — its Entertainment API addresses up to seven
lights individually.
- [x] **Pre-merge verification pass.** 1358 pytest tests pass; ruff
clean across all device modules and tests; black clean against
the pre-commit-pinned 24.10.0; `npx tsc --noEmit` clean; bundle
compiles.
- [x] **Pre-merge code review (subagent)** — surfaced 2 CRITICAL +
4 HIGH + 3 MEDIUM + 3 LOW findings.
- [x] **All review findings fixed** (commits `7736bc6` + `0e3ae78`):
- CRITICAL #1: missing `url_scheme.py` / `net_classify.py`
committed (4 files / 557 lines).
- CRITICAL #2: `update_device` no longer re-encrypts secrets in
memory via the `to_dict()` round-trip (uses `vars()` directly).
- HIGH #3: `nanoleaf_token` / `hue_username` / `hue_client_key`
stripped from `DeviceResponse`; replaced with paired-flag
booleans. Frontend updated.
- HIGH #4: `validate_lan_host()` rejects literal public IPs at
each driver's `validate_device` + `pair_device`.
- HIGH #5: `_dec()` failures clear the field and log, not crash
the row.
- HIGH #6: update route now rstrip's URL for all device types.
- MEDIUM #7: Govee discovery serialized via `asyncio.Lock`.
- MEDIUM #8: Nanoleaf mDNS browser cleanup moved to `finally`.
- MEDIUM #9: pair endpoint sanitizes URL userinfo in logs.
- LOW: Nanoleaf `.port` property added; pair-then-create E2E test
added.
- Tests: 1379 pass (+21 regression tests).
+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
+43 -11
View File
@@ -1,20 +1,52 @@
"""LED Grab - Ambient lighting based on screen content."""
from importlib.metadata import version, PackageNotFoundError
from pathlib import Path
# Fallback version — kept in sync with pyproject.toml. MUST match the
# version declared there on every release. The Windows installer build
# (build/build-dist.ps1) also patches this literal to the resolved build
# version, so any drift here is corrected for bundled distributions.
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
# on Android, where the source is included directly via source sets, or
# in the Windows bundle where the installed dist-info is stripped).
# Fallback version — patched at build time by build/build-dist.ps1 so the
# bundled Windows distribution reports the release version (the installer
# strips ledgrab-*.dist-info, so importlib.metadata fails there).
# In dev (running from source without `pip install -e .`) and on Android
# (Chaquopy embeds the source directly with no dist-info), we additionally
# read pyproject.toml so the version is always correct without manual sync.
_FALLBACK_VERSION = "0.4.2"
try:
__version__ = version("ledgrab")
except PackageNotFoundError:
__version__ = _FALLBACK_VERSION
def _read_pyproject_version() -> str | None:
"""Read version from pyproject.toml (server/pyproject.toml relative to this file).
Returns None if the file is absent (typical for installed/bundled distributions
where pyproject.toml isn't shipped) or unreadable.
"""
try:
# __init__.py -> ledgrab/ -> src/ -> server/
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
if not pyproject.is_file():
return None
try:
import tomllib # Python 3.11+
except ImportError:
return None
with pyproject.open("rb") as f:
data = tomllib.load(f)
v = data.get("project", {}).get("version")
return v if isinstance(v, str) else None
except Exception:
return None
# Prefer pyproject.toml when it sits next to the source (dev checkout). This
# avoids stale `pip install -e .` dist-info pinning an older version after a
# bump. When pyproject.toml isn't shipped (installed packages, Windows bundle,
# Android), fall back to importlib.metadata, then the patched literal.
_live = _read_pyproject_version()
if _live:
__version__ = _live
else:
try:
__version__ = version("ledgrab")
except PackageNotFoundError:
__version__ = _FALLBACK_VERSION
__author__ = "Alexei Dolgolyov"
__email__ = "dolgolyov.alexei@gmail.com"
+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,12 +29,20 @@ router = APIRouter()
_PREVIEW_ALLOWED_TYPES = {
"static",
"single_color",
"gradient",
"effect",
"daylight",
"candlelight",
"notification",
"audio",
"math_wave",
"weather",
"game_event",
"api_input",
"mapped",
"composite",
"processed",
}
@@ -89,13 +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*.
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)
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
# The game-event bus is optional in preview contexts.
try:
from ledgrab.api.dependencies import get_game_event_bus
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
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:
@@ -121,7 +181,24 @@ async def preview_color_strip_ws(
cid = None
else:
cid = None
s.start()
# Start the stream; if start() raises, release any resources we
# already acquired (clock + anything the stream itself grabbed in
# its __init__) so we don't leak refs across failed previews.
try:
s.start()
except Exception:
try:
s.stop()
except Exception as e_stop:
logger.exception("unexpected in start-failure rollback s.stop: %s", e_stop)
if cid:
scm = _get_sync_clock_manager()
if scm:
try:
scm.release(cid)
except Exception as e_rel:
logger.exception("unexpected in start-failure clock release: %s", e_rel)
raise
return s, cid
def _stop_stream(s, cid):
@@ -222,10 +299,24 @@ async def preview_color_strip_ws(
continue
new_source = _build_source(new_config)
if new_type != current_source_type:
# Source type changed — recreate stream
# Source type changed — stop the old stream first, then
# build the new one. If the rebuild fails, drop the
# reference so the frame loop doesn't keep polling a
# stopped stream and the finally-block doesn't double-stop.
_stop_stream(stream, clock_id)
stream, clock_id = _create_stream(new_source)
current_source_type = new_type
stream, clock_id = None, None
try:
stream, clock_id = _create_stream(new_source)
current_source_type = new_type
except Exception as rebuild_err:
logger.error(
f"Preview WS: failed to rebuild stream for new type {new_type}: {rebuild_err}"
)
await websocket.send_text(
_json.dumps({"type": "error", "detail": str(rebuild_err)})
)
await websocket.close(code=4003, reason=str(rebuild_err))
return
else:
stream.update_source(new_source)
if hasattr(stream, "configure"):
@@ -236,12 +327,15 @@ async def preview_color_strip_ws(
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
# Send frame
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else:
# Stream hasn't produced a frame yet — send black
if stream is None:
await websocket.send_bytes(b"\x00" * led_count * 3)
else:
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else:
# Stream hasn't produced a frame yet — send black
await websocket.send_bytes(b"\x00" * led_count * 3)
except WebSocketDisconnect:
pass
@@ -334,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
+210 -16
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
from ledgrab.api.auth import AuthRequired
from ledgrab.core.devices.led_client import (
PairingNotReady,
get_all_providers,
get_device_capabilities,
get_provider,
@@ -26,18 +27,45 @@ from ledgrab.api.schemas.devices import (
DiscoverDevicesResponse,
OpenRGBZoneResponse,
OpenRGBZonesResponse,
PairDeviceRequest,
PairDeviceResponse,
PowerRequest,
)
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
from ledgrab.utils.url_scheme import infer_http_scheme
logger = get_logger(__name__)
router = APIRouter()
def _sanitize_url_for_log(url: str) -> str:
"""Strip userinfo + fragment from a URL so secrets don't reach logs.
The pair endpoint receives a user-supplied URL on every call; if a
future driver ever accepts ``scheme://user:pass@host`` form the
credentials would land in logs without this guard.
"""
if not url:
return ""
try:
from urllib.parse import urlparse, urlunparse
parsed = urlparse(url)
# urlparse stores userinfo in `netloc`; rebuild without it.
if parsed.hostname:
netloc = parsed.hostname
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
return urlunparse((parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, ""))
except ValueError:
pass
return url
def _device_to_response(device) -> DeviceResponse:
"""Convert a Device to DeviceResponse."""
return DeviceResponse(
@@ -57,11 +85,20 @@ def _device_to_response(device) -> DeviceResponse:
dmx_protocol=device.dmx_protocol,
dmx_start_universe=device.dmx_start_universe,
dmx_start_channel=device.dmx_start_channel,
ddp_port=device.ddp_port,
ddp_destination_id=device.ddp_destination_id,
ddp_color_order=device.ddp_color_order,
espnow_peer_mac=device.espnow_peer_mac,
espnow_channel=device.espnow_channel,
hue_username=device.hue_username,
hue_client_key=device.hue_client_key,
hue_paired=bool(device.hue_username and device.hue_client_key),
hue_entertainment_group_id=device.hue_entertainment_group_id,
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
wiz_min_interval_ms=device.wiz_min_interval_ms,
lifx_min_interval_ms=device.lifx_min_interval_ms,
govee_min_interval_ms=device.govee_min_interval_ms,
opc_channel=device.opc_channel,
nanoleaf_paired=bool(device.nanoleaf_token),
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type,
@@ -134,6 +171,8 @@ async def create_device(
detail="URL is required for non-group device types.",
)
device_url = device_data.url.rstrip("/")
if device_type == "wled":
device_url = infer_http_scheme(device_url)
# ── Non-group: validate via provider ──
if device_type != "group":
@@ -168,9 +207,19 @@ async def create_device(
except HTTPException:
raise
except Exception as e:
# Don't leak the raw exception text — it can carry stack
# frames, host headers, or other internals that aren't safe
# to echo. Log with full context, return a generic message.
logger.warning(
"Failed to validate %s device at %s: %s",
device_type,
device_url,
e,
exc_info=True,
)
raise HTTPException(
status_code=422,
detail=f"Failed to connect to {device_type} device at {device_url}: {e}",
detail=f"Failed to connect to {device_type} device at {device_url}.",
)
# Resolve auto_shutdown default: False for all types
@@ -181,7 +230,7 @@ async def create_device(
# Create device in storage
device = store.create_device(
name=device_data.name,
url=device_data.url,
url=device_url,
led_count=led_count,
device_type=device_type,
baud_rate=device_data.baud_rate,
@@ -193,11 +242,45 @@ async def create_device(
dmx_protocol=device_data.dmx_protocol or "artnet",
dmx_start_universe=device_data.dmx_start_universe or 0,
dmx_start_channel=device_data.dmx_start_channel or 1,
ddp_port=device_data.ddp_port or 0,
ddp_destination_id=(
device_data.ddp_destination_id if device_data.ddp_destination_id is not None else 1
),
ddp_color_order=(
device_data.ddp_color_order if device_data.ddp_color_order is not None else 1
),
espnow_peer_mac=device_data.espnow_peer_mac or "",
espnow_channel=device_data.espnow_channel or 1,
hue_username=device_data.hue_username or "",
hue_client_key=device_data.hue_client_key or "",
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
yeelight_min_interval_ms=(
device_data.yeelight_min_interval_ms
if device_data.yeelight_min_interval_ms is not None
else 500
),
wiz_min_interval_ms=(
device_data.wiz_min_interval_ms
if device_data.wiz_min_interval_ms is not None
else 50
),
lifx_min_interval_ms=(
device_data.lifx_min_interval_ms
if device_data.lifx_min_interval_ms is not None
else 50
),
govee_min_interval_ms=(
device_data.govee_min_interval_ms
if device_data.govee_min_interval_ms is not None
else 50
),
opc_channel=(device_data.opc_channel if device_data.opc_channel is not None else 0),
nanoleaf_token=device_data.nanoleaf_token or "",
nanoleaf_min_interval_ms=(
device_data.nanoleaf_min_interval_ms
if device_data.nanoleaf_min_interval_ms is not None
else 100
),
spi_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink",
@@ -233,6 +316,79 @@ async def create_device(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post(
"/api/v1/devices/pair",
response_model=PairDeviceResponse,
tags=["Devices"],
)
async def pair_device(
body: PairDeviceRequest,
_auth: AuthRequired,
):
"""Run a pairing handshake against a device before creating it.
The frontend opens this endpoint after the user has performed the
device's physical pairing action (e.g. held the power button for 5s).
The response carries provider-specific fields the caller must include
in the subsequent ``POST /api/v1/devices`` body.
Status codes:
200 paired — fields returned
400 unknown device type, or device type does not support pairing
409 device not ready — user must perform the physical action
(or retry, e.g. the pairing window timed out)
422 invalid URL or device configuration
"""
try:
provider = get_provider(body.device_type)
except ValueError:
raise HTTPException(status_code=400, detail=f"Unknown device type: {body.device_type}")
try:
fields = await provider.pair_device(body.url)
except NotImplementedError:
raise HTTPException(
status_code=400,
detail=f"Device type {body.device_type!r} does not support pairing",
)
except PairingNotReady as exc:
raise HTTPException(status_code=409, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except Exception as exc:
# Strip userinfo before logging so a `scheme://user:pass@host` URL
# never lands in the logs (no shipped driver uses userinfo today,
# but the pattern is a foot-gun for the next driver author --
# caught by review MEDIUM #9). Also keep exc_info=False so a
# provider stack trace that may include response bytes from a
# hostile receiver doesn't end up in the file either.
safe_url = _sanitize_url_for_log(body.url)
logger.warning(
"Pairing failed for %s at %s: %s: %s",
body.device_type,
safe_url,
type(exc).__name__,
exc,
)
raise HTTPException(
status_code=502,
detail=f"Pairing failed for {body.device_type} at {safe_url}.",
)
if not isinstance(fields, dict):
logger.warning(
"Provider %s.pair_device returned %r (expected dict)",
body.device_type,
type(fields).__name__,
)
raise HTTPException(
status_code=500,
detail=f"Provider {body.device_type!r} returned malformed pairing result",
)
return PairDeviceResponse(fields=fields)
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
async def list_devices(
_auth: AuthRequired,
@@ -266,11 +422,20 @@ async def discover_devices(
raise HTTPException(status_code=400, detail=f"Unknown device type: {device_type}")
discovered = await provider.discover(timeout=capped_timeout)
else:
# Discover from all providers in parallel
# Discover from all providers in parallel. Discovery is best-effort:
# one provider failing (firewall, missing dep, mDNS race) must not
# take the entire scan down, so collect exceptions instead of
# raising and log them individually.
providers = get_all_providers()
discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()]
all_results = await asyncio.gather(*discover_tasks)
discovered = [d for batch in all_results for d in batch]
provider_items = list(providers.items())
discover_tasks = [p.discover(timeout=capped_timeout) for _, p in provider_items]
all_results = await asyncio.gather(*discover_tasks, return_exceptions=True)
discovered = []
for (name, _), result in zip(provider_items, all_results):
if isinstance(result, BaseException):
logger.warning("Discovery failed for provider %s: %s", name, result)
continue
discovered.extend(result)
elapsed_ms = (time.time() - start) * 1000
existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()}
@@ -385,6 +550,26 @@ async def update_device(
existing = store.get_device(device_id)
is_group = existing.device_type == "group"
# Normalize URL the same way we do on create:
# * always rstrip trailing slashes (so PUT-with-trailing-/ matches
# POST-with-trailing-/ in the stored value -- caught by review HIGH #6)
# * only WLED gets http/https scheme inference; other schemes
# (yeelight://, lifx://, opc://, ddp://, …) pass through.
# Done via a local rather than mutating the request DTO so the
# input is preserved for any future caller that inspects it.
normalized_url = update_data.url
if update_data.url:
normalized_url = update_data.url.rstrip("/")
if existing.device_type == "wled":
inferred = infer_http_scheme(normalized_url)
if inferred != normalized_url:
logger.debug("Inferred WLED URL scheme: %r -> %r", normalized_url, inferred)
normalized_url = inferred
# Group-only field overrides (led_count auto-recompute) are accumulated
# here too so the update_data Pydantic model is not mutated in place.
normalized_led_count = update_data.led_count
if is_group:
new_children = update_data.group_device_ids
new_mode = update_data.group_mode or existing.group_mode
@@ -405,20 +590,20 @@ async def update_device(
# Auto-recompute led_count for sequence mode
if effective_mode == "sequence":
update_data.led_count = store.resolve_group_led_count(effective_children)
normalized_led_count = store.resolve_group_led_count(effective_children)
elif (
update_data.led_count is None
normalized_led_count is None
and new_mode == "independent"
and new_children is not None
):
update_data.led_count = store.resolve_group_max_led_count(effective_children)
normalized_led_count = store.resolve_group_max_led_count(effective_children)
device = store.update_device(
device_id=device_id,
name=update_data.name,
url=update_data.url,
url=normalized_url,
enabled=update_data.enabled,
led_count=update_data.led_count,
led_count=normalized_led_count,
baud_rate=update_data.baud_rate,
auto_shutdown=update_data.auto_shutdown,
send_latency_ms=update_data.send_latency_ms,
@@ -428,11 +613,21 @@ async def update_device(
dmx_protocol=update_data.dmx_protocol,
dmx_start_universe=update_data.dmx_start_universe,
dmx_start_channel=update_data.dmx_start_channel,
ddp_port=update_data.ddp_port,
ddp_destination_id=update_data.ddp_destination_id,
ddp_color_order=update_data.ddp_color_order,
espnow_peer_mac=update_data.espnow_peer_mac,
espnow_channel=update_data.espnow_channel,
hue_username=update_data.hue_username,
hue_client_key=update_data.hue_client_key,
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
govee_min_interval_ms=update_data.govee_min_interval_ms,
opc_channel=update_data.opc_channel,
nanoleaf_token=update_data.nanoleaf_token,
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
spi_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type,
@@ -449,13 +644,12 @@ async def update_device(
try:
manager.update_device_info(
device_id,
device_url=update_data.url,
led_count=update_data.led_count,
device_url=normalized_url,
led_count=normalized_led_count,
baud_rate=update_data.baud_rate,
)
except ValueError as e:
logger.debug("Processor manager device update skipped for %s: %s", device_id, e)
pass
# Sync auto_shutdown and zone_mode in runtime state
ds = manager.find_device_state(device_id)
@@ -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,
)
+359 -165
View File
@@ -1,7 +1,7 @@
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
from typing import Annotated
from typing import Annotated, Optional
from fastapi import APIRouter, Body, HTTPException, Depends
@@ -9,18 +9,27 @@ from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_device_store,
get_mqtt_store,
get_output_target_store,
get_processor_manager,
get_value_source_store,
)
from ledgrab.api.schemas.output_targets import (
HALightMappingSchema,
HALightOutputTargetCreate,
HALightOutputTargetResponse,
HALightOutputTargetUpdate,
LedOutputTargetCreate,
LedOutputTargetResponse,
LedOutputTargetUpdate,
OutputTargetCreate,
OutputTargetListResponse,
OutputTargetResponse,
OutputTargetUpdate,
Z2MLightMappingSchema,
Z2MLightOutputTargetCreate,
Z2MLightOutputTargetResponse,
Z2MLightOutputTargetUpdate,
)
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore
@@ -30,6 +39,11 @@ from ledgrab.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from ledgrab.storage.z2m_light_output_target import (
Z2MLightMapping,
Z2MLightOutputTarget,
)
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.utils import get_logger
@@ -99,6 +113,42 @@ def _ha_light_target_to_response(
)
def _z2m_light_target_to_response(
target: Z2MLightOutputTarget,
) -> Z2MLightOutputTargetResponse:
"""Convert a Z2MLightOutputTarget to Z2MLightOutputTargetResponse."""
return Z2MLightOutputTargetResponse(
id=target.id,
name=target.name,
mqtt_source_id=target.mqtt_source_id or "",
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
color_strip_source_id=target.color_strip_source_id or "",
color_value_source_id=target.color_value_source_id or "",
brightness=target.brightness.to_dict(),
z2m_light_mappings=[
Z2MLightMappingSchema(
friendly_name=m.friendly_name,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale.to_dict(),
)
for m in target.light_mappings
],
base_topic=target.base_topic,
update_rate=target.update_rate.to_dict(),
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
stop_action=target.stop_action if target.stop_action in ("none", "turn_off") else "none",
description=target.description,
tags=target.tags,
icon=getattr(target, "icon", "") or "",
icon_color=getattr(target, "icon_color", "") or "",
created_at=target.created_at,
updated_at=target.updated_at,
)
def _validate_color_value_source(
value_source_store: ValueSourceStore, color_value_source_id: str
) -> None:
@@ -125,27 +175,111 @@ 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)
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 =====
def _build_ha_mappings(
payload: list[HALightMappingSchema] | None,
) -> list[HALightMapping] | None:
if not payload:
return None
return [
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in payload
]
def _build_z2m_mappings(
payload: list[Z2MLightMappingSchema] | None,
) -> list[Z2MLightMapping] | None:
if not payload:
return None
return [
Z2MLightMapping(
friendly_name=m.friendly_name,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in payload
]
def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
if not device_id:
return
try:
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
def _validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str) -> None:
"""Ensure the referenced MQTT source exists. Empty id is allowed (unconfigured)."""
if not mqtt_source_id:
return
try:
mqtt_store.get(mqtt_source_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
@router.post(
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
)
@@ -156,65 +290,69 @@ async def create_target(
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
value_source_store: ValueSourceStore = Depends(get_value_source_store),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
):
"""Create a new output target."""
try:
# Validate device exists if provided
device_id = getattr(data, "device_id", "")
if device_id:
try:
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
# Validate color VS reference for HA-light targets in color_vs mode
if (
getattr(data, "target_type", "") == "ha_light"
and getattr(data, "source_kind", "css") == "color_vs"
):
_validate_color_value_source(
value_source_store, getattr(data, "color_value_source_id", "")
)
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = (
[
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
match data:
case LedOutputTargetCreate():
_validate_device_exists(device_store, data.device_id)
target = target_store.create_wled_target(
name=data.name,
description=data.description,
tags=data.tags,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
)
for m in ha_light_mappings_raw
]
if ha_light_mappings_raw
else None
)
# Create in store
target = target_store.create_target(
name=data.name,
target_type=data.target_type,
device_id=device_id,
color_strip_source_id=getattr(data, "color_strip_source_id", ""),
brightness=getattr(data, "brightness", 1.0),
fps=getattr(data, "fps", 30),
keepalive_interval=getattr(data, "keepalive_interval", 1.0),
state_check_interval=getattr(data, "state_check_interval", 30),
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
adaptive_fps=getattr(data, "adaptive_fps", False),
protocol=getattr(data, "protocol", "ddp"),
description=data.description,
tags=data.tags,
ha_source_id=getattr(data, "ha_source_id", ""),
source_kind=getattr(data, "source_kind", "css"),
color_value_source_id=getattr(data, "color_value_source_id", ""),
ha_light_mappings=ha_mappings,
update_rate=getattr(data, "update_rate", 2.0),
transition=getattr(data, "transition", 0.5),
color_tolerance=getattr(data, "color_tolerance", 5),
stop_action=getattr(data, "stop_action", "none"),
)
case HALightOutputTargetCreate():
if data.source_kind == "color_vs":
_validate_color_value_source(value_source_store, data.color_value_source_id)
target = target_store.create_ha_light_target(
name=data.name,
description=data.description,
tags=data.tags,
ha_source_id=data.ha_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
case Z2MLightOutputTargetCreate():
if data.source_kind == "color_vs":
_validate_color_value_source(value_source_store, data.color_value_source_id)
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.create_z2m_light_target(
name=data.name,
description=data.description,
tags=data.tags,
mqtt_source_id=data.mqtt_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
base_topic=data.base_topic,
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
raise HTTPException(status_code=400, detail="Unknown target_type")
# Register in processor manager
try:
@@ -282,6 +420,18 @@ async def get_target(
raise HTTPException(status_code=404, detail=str(e))
def _resolve_effective_color_vs_id(
target_store: OutputTargetStore, target_id: str, payload_id: Optional[str]
) -> str:
if payload_id is not None:
return payload_id
try:
existing = target_store.get_target(target_id)
except ValueError:
return ""
return getattr(existing, "color_value_source_id", "") or ""
@router.put(
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
)
@@ -293,116 +443,160 @@ async def update_target(
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
value_source_store: ValueSourceStore = Depends(get_value_source_store),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
):
"""Update a output target."""
try:
# Validate device exists if changing
device_id = getattr(data, "device_id", None)
if device_id is not None and device_id:
try:
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
css_changed = False
brightness_changed = False
settings_changed = False
device_changed = False
# Validate color VS reference for HA-light targets switching into / staying in color_vs
if getattr(data, "target_type", "") == "ha_light":
new_kind = getattr(data, "source_kind", None)
new_color_vs = getattr(data, "color_value_source_id", None)
if new_kind == "color_vs" or (new_kind is None and new_color_vs):
# Determine effective id: payload id if provided, else existing target's id
effective_id = new_color_vs
if effective_id is None:
try:
existing = target_store.get_target(target_id)
effective_id = getattr(existing, "color_value_source_id", "")
except ValueError:
effective_id = ""
_validate_color_value_source(value_source_store, effective_id or "")
# Build HA light mappings if provided
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = None
if ha_light_mappings_raw is not None:
ha_mappings = [
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
match data:
case LedOutputTargetUpdate():
if data.device_id:
_validate_device_exists(device_store, data.device_id)
target = target_store.update_wled_target(
target_id,
name=data.name,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
)
for m in ha_light_mappings_raw
]
# Update in store
target = target_store.update_target(
target_id=target_id,
name=data.name,
device_id=device_id,
color_strip_source_id=getattr(data, "color_strip_source_id", None),
brightness=getattr(data, "brightness", None),
fps=getattr(data, "fps", None),
keepalive_interval=getattr(data, "keepalive_interval", None),
state_check_interval=getattr(data, "state_check_interval", None),
min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
adaptive_fps=getattr(data, "adaptive_fps", None),
protocol=getattr(data, "protocol", None),
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
ha_source_id=getattr(data, "ha_source_id", None),
source_kind=getattr(data, "source_kind", None),
color_value_source_id=getattr(data, "color_value_source_id", None),
ha_light_mappings=ha_mappings,
update_rate=getattr(data, "update_rate", None),
transition=getattr(data, "transition", None),
color_tolerance=getattr(data, "color_tolerance", None),
stop_action=getattr(data, "stop_action", None),
)
css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None
settings_changed = any(
v is not None
for v in (
data.fps,
data.keepalive_interval,
data.state_check_interval,
data.min_brightness_threshold,
data.adaptive_fps,
data.brightness,
)
)
device_changed = data.device_id is not None
case HALightOutputTargetUpdate():
# Validate color VS when switching into / staying in color_vs mode
if data.source_kind == "color_vs" or (
data.source_kind is None and data.color_value_source_id
):
effective_id = _resolve_effective_color_vs_id(
target_store, target_id, data.color_value_source_id
)
_validate_color_value_source(value_source_store, effective_id)
target = target_store.update_ha_light_target(
target_id,
name=data.name,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
ha_source_id=data.ha_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None
settings_changed = any(
v is not None
for v in (
data.source_kind,
data.color_value_source_id,
data.brightness,
data.update_rate,
data.transition,
data.min_brightness_threshold,
data.color_tolerance,
data.ha_light_mappings,
data.stop_action,
)
)
case Z2MLightOutputTargetUpdate():
if data.source_kind == "color_vs" or (
data.source_kind is None and data.color_value_source_id
):
effective_id = _resolve_effective_color_vs_id(
target_store, target_id, data.color_value_source_id
)
_validate_color_value_source(value_source_store, effective_id)
if data.mqtt_source_id:
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.update_z2m_light_target(
target_id,
name=data.name,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
mqtt_source_id=data.mqtt_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
base_topic=data.base_topic,
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None
settings_changed = any(
v is not None
for v in (
data.source_kind,
data.color_value_source_id,
data.mqtt_source_id,
data.brightness,
data.base_topic,
data.update_rate,
data.transition,
data.min_brightness_threshold,
data.color_tolerance,
data.z2m_light_mappings,
data.stop_action,
)
)
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
raise HTTPException(status_code=400, detail="Unknown target_type")
# Sync processor manager (run in thread — css release/acquire can block)
color_strip_source_id = getattr(data, "color_strip_source_id", None)
fps = getattr(data, "fps", None)
keepalive_interval = getattr(data, "keepalive_interval", None)
state_check_interval = getattr(data, "state_check_interval", None)
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
adaptive_fps = getattr(data, "adaptive_fps", None)
update_rate = getattr(data, "update_rate", None)
transition = getattr(data, "transition", None)
color_tolerance = getattr(data, "color_tolerance", None)
brightness = getattr(data, "brightness", None)
stop_action = getattr(data, "stop_action", None)
source_kind = getattr(data, "source_kind", None)
color_value_source_id = getattr(data, "color_value_source_id", None)
try:
await asyncio.to_thread(
target.sync_with_manager,
manager,
settings_changed=(
fps is not None
or keepalive_interval is not None
or state_check_interval is not None
or min_brightness_threshold is not None
or adaptive_fps is not None
or update_rate is not None
or transition is not None
or color_tolerance is not None
or ha_light_mappings_raw is not None
or brightness is not None
or stop_action is not None
or source_kind is not None
or color_value_source_id is not None
),
css_changed=color_strip_source_id is not None,
brightness_changed=brightness is not None,
settings_changed=settings_changed,
css_changed=css_changed,
brightness_changed=brightness_changed,
)
except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
pass
# Device change requires async stop -> swap -> start cycle
if device_id is not None:
# LED-only: device change requires async stop -> swap -> start cycle
if device_changed and isinstance(target, WledOutputTarget):
try:
await manager.update_target_device(target_id, target.device_id)
except ValueError as e:
@@ -335,6 +335,35 @@ async def get_overlay_status(
raise HTTPException(status_code=404, detail=str(e))
# ===== HA LIGHT — MANUAL TURN OFF =====
@router.post("/api/v1/output-targets/{target_id}/ha-light/turn-off", tags=["Processing"])
async def turn_off_ha_light_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Turn off all HA light entities mapped by the target.
Works regardless of whether the target's processor is running. Useful
when ``stop_action`` is ``"none"`` and lights were left on after a stop.
"""
try:
# Verify target exists
target_store.get_target(target_id)
count = await manager.turn_off_ha_light_target(target_id)
return {"status": "ok", "target_id": target_id, "entities": count}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error("Failed to turn off HA lights: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== HA LIGHT COLOR PREVIEW WEBSOCKET =====
@@ -377,6 +406,75 @@ async def ha_light_colors_ws(
manager.remove_ha_light_ws_client(target_id, websocket)
# ===== Z2M LIGHT — MANUAL TURN OFF =====
@router.post("/api/v1/output-targets/{target_id}/z2m-light/turn-off", tags=["Processing"])
async def turn_off_z2m_light_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Publish OFF to all Z2M bulbs mapped by the target.
Works regardless of whether the target's processor is running. Useful
when ``stop_action`` is ``"none"`` and bulbs were left on after a stop.
"""
try:
target_store.get_target(target_id)
count = await manager.turn_off_z2m_light_target(target_id)
return {"status": "ok", "target_id": target_id, "entities": count}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error("Failed to turn off Z2M lights: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== Z2M LIGHT COLOR PREVIEW WEBSOCKET =====
@router.websocket("/api/v1/output-targets/{target_id}/z2m-light/ws")
async def z2m_light_colors_ws(
websocket: WebSocket,
target_id: str,
):
"""WebSocket for live Z2M bulb colour preview.
Streams: {"type":"colors_update","colors":{friendly_name:{r,g,b,hex},...}}
at the target's update_rate. Auth via first-message handshake.
"""
from ledgrab.api.auth import accept_and_authenticate_ws
if await accept_and_authenticate_ws(websocket) is None:
return
manager: ProcessorManager = get_processor_manager()
try:
proc = manager._processors.get(target_id)
if not proc or not proc.is_running:
await websocket.close(code=4003, reason="Target not running")
return
except Exception as e:
await websocket.close(code=4004, reason=str(e))
return
try:
manager.add_z2m_light_ws_client(target_id, websocket)
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
except (RuntimeError, ConnectionError) as e:
logger.debug("ws closed in z2m-light client: %s", e)
finally:
manager.remove_z2m_light_ws_client(target_id, websocket)
# ===== LED PREVIEW WEBSOCKET =====
+10 -11
View File
@@ -25,6 +25,7 @@ from ledgrab.api.dependencies import (
get_device_store,
get_ha_manager,
get_ha_store,
get_mqtt_manager,
get_output_target_store,
get_picture_source_store,
get_pp_template_store,
@@ -380,22 +381,20 @@ async def get_integrations_status(
_: AuthRequired,
ha_store=Depends(get_ha_store),
ha_manager=Depends(get_ha_manager),
mqtt_manager=Depends(get_mqtt_manager),
):
"""Return connection status for external integrations (MQTT, Home Assistant).
Used by the dashboard to show connectivity indicators.
Used by the dashboard to show connectivity indicators. MQTT is reported
per-source since the multi-broker refactor — no more global "MQTT
enabled" flag.
"""
from ledgrab.core.devices.mqtt_client import get_mqtt_service
# MQTT status
mqtt_service = get_mqtt_service()
mqtt_config = get_config().mqtt
# MQTT status — one entry per configured source
mqtt_items = mqtt_manager.get_all_sources_status()
mqtt_status = {
"enabled": mqtt_config.enabled,
"connected": mqtt_service.is_connected if mqtt_service else False,
"broker": (
f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None
),
"sources": mqtt_items,
"total": len(mqtt_items),
"connected": sum(1 for s in mqtt_items if s.get("connected")),
}
# Home Assistant status
@@ -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"),
]
+139 -2
View File
@@ -37,6 +37,19 @@ class DeviceCreate(BaseModel):
dmx_start_channel: Optional[int] = Field(
None, ge=1, le=512, description="DMX start channel (1-512)"
)
# DDP fields
ddp_port: Optional[int] = Field(
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
)
ddp_destination_id: Optional[int] = Field(
None, ge=0, le=255, description="DDP destination ID (default 1 = display)"
)
ddp_color_order: Optional[int] = Field(
None,
ge=0,
le=5,
description="DDP color order: 0=GRB 1=RGB 2=BRG 3=RBG 4=BGR 5=GBR (most receivers expect RGB)",
)
# ESP-NOW fields
espnow_peer_mac: Optional[str] = Field(
None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)"
@@ -50,6 +63,53 @@ class DeviceCreate(BaseModel):
hue_entertainment_group_id: Optional[str] = Field(
None, description="Hue entertainment group/zone ID"
)
# Yeelight fields
yeelight_min_interval_ms: Optional[int] = Field(
None,
ge=0,
le=10000,
description="Yeelight client-side rate limit between commands in ms (default 500)",
)
# WiZ fields
wiz_min_interval_ms: Optional[int] = Field(
None,
ge=0,
le=10000,
description="WiZ client-side rate limit between commands in ms (default 50)",
)
# LIFX fields
lifx_min_interval_ms: Optional[int] = Field(
None,
ge=0,
le=10000,
description="LIFX client-side rate limit between commands in ms (default 50)",
)
# Govee fields
govee_min_interval_ms: Optional[int] = Field(
None,
ge=0,
le=10000,
description="Govee client-side rate limit between commands in ms (default 50)",
)
# OPC fields
opc_channel: Optional[int] = Field(
None,
ge=0,
le=255,
description="OPC channel (0 = broadcast to all channels on the server)",
)
# Nanoleaf fields
nanoleaf_token: Optional[str] = Field(
None,
max_length=512,
description="Nanoleaf auth token returned by the pairing handshake",
)
nanoleaf_min_interval_ms: Optional[int] = Field(
None,
ge=0,
le=10000,
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
)
# SPI Direct fields
spi_speed_hz: Optional[int] = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
@@ -126,6 +186,11 @@ class DeviceUpdate(BaseModel):
dmx_start_channel: Optional[int] = Field(
None, ge=1, le=512, description="DMX start channel (1-512)"
)
ddp_port: Optional[int] = Field(
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
)
ddp_destination_id: Optional[int] = Field(None, ge=0, le=255, description="DDP destination ID")
ddp_color_order: Optional[int] = Field(None, ge=0, le=5, description="DDP color order code")
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address")
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
hue_username: Optional[str] = Field(None, description="Hue bridge username")
@@ -133,6 +198,25 @@ class DeviceUpdate(BaseModel):
hue_entertainment_group_id: Optional[str] = Field(
None, description="Hue entertainment group ID"
)
yeelight_min_interval_ms: Optional[int] = Field(
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
)
wiz_min_interval_ms: Optional[int] = Field(
None, ge=0, le=10000, description="WiZ client-side rate limit in ms"
)
lifx_min_interval_ms: Optional[int] = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
)
govee_min_interval_ms: Optional[int] = Field(
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
)
opc_channel: Optional[int] = Field(
None, ge=0, le=255, description="OPC channel (0 = broadcast)"
)
nanoleaf_token: Optional[str] = Field(None, max_length=512, description="Nanoleaf auth token")
nanoleaf_min_interval_ms: Optional[int] = Field(
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
)
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
@@ -164,6 +248,32 @@ class DeviceUpdate(BaseModel):
)
class PairDeviceRequest(BaseModel):
"""Initiate a pairing handshake with a device before creating it.
The caller is expected to have just performed the device's physical
pairing action (e.g. holding the power button on a Nanoleaf for 5 s,
pressing the Hue bridge link button). The response carries any
provider-specific fields the frontend must include in the subsequent
``POST /api/v1/devices`` payload typically an auth token.
"""
device_type: str = Field(description="Device type identifier (e.g. 'nanoleaf')")
url: str = Field(description="Device URL (e.g. 'nanoleaf://192.168.1.50')")
class PairDeviceResponse(BaseModel):
"""Successful pairing result. ``fields`` is merged into the create payload."""
fields: Dict[str, object] = Field(
default_factory=dict,
description=(
"Provider-specific fields to include in the subsequent device-create "
"request (e.g. {'nanoleaf_token': 'abc...'})."
),
)
class CalibrationLineSchema(BaseModel):
"""One LED line in advanced calibration."""
@@ -294,11 +404,38 @@ class DeviceResponse(BaseModel):
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
dmx_start_universe: int = Field(default=0, description="DMX start universe")
dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)")
ddp_port: int = Field(default=0, description="DDP UDP port (0 = protocol default 4048)")
ddp_destination_id: int = Field(default=1, description="DDP destination ID")
ddp_color_order: int = Field(default=1, description="DDP color order code (1 = RGB)")
espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address")
espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel")
hue_username: str = Field(default="", description="Hue bridge username")
hue_client_key: str = Field(default="", description="Hue entertainment client key")
hue_paired: bool = Field(
default=False,
description=(
"Whether the Hue bridge has been paired (i.e. a username/client_key "
"is on file). The actual credentials are intentionally not exposed "
"in the response -- to re-pair, delete and re-add the device."
),
)
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
yeelight_min_interval_ms: int = Field(
default=500, description="Yeelight client-side rate limit in ms"
)
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
nanoleaf_paired: bool = Field(
default=False,
description=(
"Whether the Nanoleaf auth token has been issued by the pairing "
"handshake. The token itself is intentionally not exposed in the "
"response -- to re-pair, delete and re-add the device."
),
)
nanoleaf_min_interval_ms: int = Field(
default=100, description="Nanoleaf client-side rate limit in ms"
)
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
@@ -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
@@ -43,6 +43,20 @@ class HALightMappingSchema(BaseModel):
)
class Z2MLightMappingSchema(BaseModel):
"""Maps an LED range to one Zigbee2MQTT bulb (by friendly name)."""
friendly_name: str = Field(
description="Z2M friendly_name (e.g. 'living_room_bulb_1')",
min_length=1,
)
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
brightness_scale: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness multiplier (bindable)"
)
# =====================================================================
# Response schemas (per-type, discriminated union)
# =====================================================================
@@ -119,10 +133,56 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
)
class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["z2m_light"] = "z2m_light"
mqtt_source_id: str = Field(
default="",
description="MQTT source (broker) the target publishes to. Empty = unconfigured.",
)
source_kind: Literal["css", "color_vs"] = Field(
default="css",
description="Colour source kind: 'css' (per-mapping LED segments) or "
"'color_vs' (single colour value source applied to all bulbs).",
)
color_strip_source_id: str = Field(
default="", description="Color strip source ID (used when source_kind='css')"
)
color_value_source_id: str = Field(
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
)
base_topic: str = Field(
default="zigbee2mqtt",
description="Z2M MQTT base topic prefix (override if your Z2M instance is non-default).",
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Publish rate Hz (bindable; 0.5-10)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Literal["none", "turn_off"] = Field(
default="none",
description="What to do with mapped bulbs when the target stops: "
"'none' (leave as-is) or 'turn_off'.",
)
OutputTargetResponse = Annotated[
Union[
Annotated[LedOutputTargetResponse, Tag("led")],
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
],
Discriminator("target_type"),
]
@@ -222,10 +282,58 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
)
class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["z2m_light"] = "z2m_light"
mqtt_source_id: str = Field(
default="",
description="MQTT source (broker) the target publishes to. Required to start.",
)
source_kind: Literal["css", "color_vs"] = Field(
default="css",
description="Colour source kind: 'css' or 'color_vs'.",
)
color_strip_source_id: str = Field(
default="", description="Color strip source ID (used when source_kind='css')"
)
color_value_source_id: str = Field(
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
)
base_topic: str = Field(
default="zigbee2mqtt",
max_length=128,
description="Z2M MQTT base topic prefix.",
)
update_rate: Optional[BindableFloatInput] = Field(
default=5.0, description="Publish rate in Hz (bindable; 0.5-10)"
)
transition: Optional[BindableFloatInput] = Field(
default=0.3, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
default=5, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
stop_action: Literal["none", "turn_off"] = Field(
default="none",
description="Finalization on stop: 'none' or 'turn_off'.",
)
OutputTargetCreate = Annotated[
Union[
Annotated[LedOutputTargetCreate, Tag("led")],
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
],
Discriminator("target_type"),
]
@@ -309,10 +417,48 @@ class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
)
class Z2MLightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["z2m_light"] = "z2m_light"
mqtt_source_id: Optional[str] = Field(
None,
description="MQTT source (broker) id. Empty string clears the binding.",
)
source_kind: Optional[Literal["css", "color_vs"]] = Field(
None, description="Colour source kind: 'css' or 'color_vs'."
)
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
color_value_source_id: Optional[str] = Field(
None, description="Colour value source ID (used when source_kind='color_vs')."
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
)
base_topic: Optional[str] = Field(
None, max_length=128, description="Z2M MQTT base topic prefix."
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Publish rate Hz (bindable; 0.5-10)"
)
transition: Optional[BindableFloatInput] = Field(
None, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Optional[Literal["none", "turn_off"]] = Field(
None, description="Finalization on stop: 'none' or 'turn_off'."
)
OutputTargetUpdate = Annotated[
Union[
Annotated[LedOutputTargetUpdate, Tag("led")],
Annotated[HALightOutputTargetUpdate, Tag("ha_light")],
Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")],
],
Discriminator("target_type"),
]
@@ -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"),
]
+8 -47
View File
@@ -83,52 +83,10 @@ class StorageConfig(BaseSettings):
database_file: str = f"{_DEFAULT_DATA_DIR_STR}/ledgrab.db"
class MQTTConfig(BaseSettings):
"""MQTT broker configuration.
The ``password`` field accepts either plaintext or an ``ENC:v1:`` envelope
(see :mod:`ledgrab.utils.secret_box`). Use :func:`resolve_mqtt_password`
to obtain the plaintext value at runtime.
"""
enabled: bool = False
broker_host: str = "localhost"
broker_port: int = 1883
username: str = ""
password: str = ""
client_id: str = "ledgrab"
base_topic: str = "ledgrab"
def resolve_mqtt_password(config: "Config | None" = None) -> str:
"""Return the plaintext MQTT password.
Accepts either an ``ENC:v1:`` envelope or legacy plaintext. If
plaintext is detected, a warning is logged once per process start
so the user knows to migrate.
"""
from ledgrab.utils import get_logger, secret_box
log = get_logger(__name__)
config = config or get_config()
pw = config.mqtt.password or ""
if not pw:
return ""
if secret_box.is_encrypted(pw):
try:
return secret_box.decrypt(pw)
except Exception as exc:
log.error("Failed to decrypt MQTT password: %s", exc)
return ""
# Plaintext — warn (once)
if not getattr(resolve_mqtt_password, "_warned", False):
log.warning(
"MQTT password in config.yaml is stored in plaintext. "
"Replace with an encrypted envelope (ENC:v1:...) — see "
"ledgrab.utils.secret_box.encrypt()."
)
resolve_mqtt_password._warned = True # type: ignore[attr-defined]
return pw
# The legacy single-broker ``MQTTConfig`` block has been removed. Brokers
# are now first-class :class:`MQTTSource` entries managed through the UI;
# see :mod:`ledgrab.core.mqtt.legacy_migration` for the one-shot upgrade
# path that seeds an MQTTSource from any pre-existing ``mqtt:`` YAML block.
class LoggingConfig(BaseSettings):
@@ -158,6 +116,10 @@ class Config(BaseSettings):
env_prefix="LEDGRAB_",
env_nested_delimiter="__",
case_sensitive=False,
# ``extra="ignore"`` lets pre-existing YAML files (with the now-removed
# ``mqtt:`` block, etc.) load without raising. The legacy MQTT block
# is handled by ``core.mqtt.legacy_migration`` on first startup.
extra="ignore",
)
demo: bool = False
@@ -166,7 +128,6 @@ class Config(BaseSettings):
auth: AuthConfig = Field(default_factory=AuthConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
assets: AssetsConfig = Field(default_factory=AssetsConfig)
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
updates: UpdatesConfig = Field(default_factory=UpdatesConfig)
@@ -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."""
@@ -33,19 +82,21 @@ class AutomationEngine:
automation_store: AutomationStore,
processor_manager,
poll_interval: float = 1.0,
mqtt_service=None,
scene_preset_store=None,
target_store=None,
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_service = mqtt_service
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
@@ -67,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")
@@ -96,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")
@@ -185,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:
@@ -200,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()
@@ -339,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,
@@ -349,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:
@@ -393,20 +544,14 @@ class AutomationEngine:
return display_state == rule.state
def _evaluate_mqtt(self, rule: MQTTRule) -> bool:
value = None
# Try entity-based manager first (new model)
if self._mqtt_manager is not None and rule.mqtt_source_id:
runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
if runtime and runtime.is_connected:
value = runtime.get_last_value(rule.topic)
elif self._mqtt_manager is not None:
# No source specified — try first available runtime
runtime = self._mqtt_manager.get_first_runtime()
if runtime:
value = runtime.get_last_value(rule.topic)
# Fallback to legacy global service
if value is None and self._mqtt_service is not None and self._mqtt_service.is_connected:
value = self._mqtt_service.get_last_value(rule.topic)
# Multi-broker model: the rule references a specific MQTTSource.
# Rules without one are no-ops (UI should enforce a source on save).
if self._mqtt_manager is None or not rule.mqtt_source_id:
return False
runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
if runtime is None or not runtime.is_connected:
return False
value = runtime.get_last_value(rule.topic)
if value is None:
return False
matchers = {
@@ -444,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,
@@ -644,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 -166
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,104 +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
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.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
sums_buf = np.empty((led_count, 3), dtype=np.float64)
starts_buf = np.empty((led_count, 3), dtype=np.float64)
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.
@@ -666,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.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
sums_buf = np.empty((led_count, 3), dtype=np.float64)
starts_buf = np.empty((led_count, 3), dtype=np.float64)
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,
@@ -731,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:
@@ -192,8 +198,11 @@ def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10)
left = img[:, :border_width, :]
logger.debug(
f"Extracted borders: top={top.shape}, right={right.shape}, "
f"bottom={bottom.shape}, left={left.shape}"
"Extracted borders",
top=top.shape,
right=right.shape,
bottom=bottom.shape,
left=left.shape,
)
return BorderPixels(
@@ -303,6 +312,12 @@ def calculate_median_color(pixels: np.ndarray) -> tuple[int, int, int]:
def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
"""Calculate dominant color of a pixel region using simple clustering.
Quantizes to 32 levels/channel (5 bits/channel = 32K bins), packs into a
single uint32, then uses ``np.bincount`` to find the most common bin.
Sampling uses with-replacement (statistically equivalent for a dominant-bin
search and avoids the full sort that ``np.random.choice(replace=False)``
triggers internally).
Args:
pixels: Pixel array (height, width, 3)
@@ -312,28 +327,27 @@ def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
if pixels.size == 0:
return (0, 0, 0)
# Reshape to (n_pixels, 3)
pixels_reshaped = pixels.reshape(-1, 3)
n = len(pixels_reshaped)
# For performance, sample pixels if there are too many
max_samples = 1000
if len(pixels_reshaped) > max_samples:
indices = np.random.choice(len(pixels_reshaped), max_samples, replace=False)
if 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]
# Simple dominant color: quantize colors and find most common
# Reduce color space to 32 levels per channel for binning
quantized = (pixels_reshaped // 8) * 8
# Find unique colors and their counts
unique_colors, counts = np.unique(quantized, axis=0, return_counts=True)
# Get the most common color
dominant_idx = np.argmax(counts)
dominant_color = unique_colors[dominant_idx]
r = int(np.clip(dominant_color[0], 0, 255))
g = int(np.clip(dominant_color[1], 0, 255))
b = int(np.clip(dominant_color[2], 0, 255))
# Quantize to 32 levels/channel (drop low 3 bits) and pack into uint32:
# bits 10-14 = R, bits 5-9 = G, bits 0-4 = B → 32K possible bins.
q = pixels_reshaped >> 3 # uint8 in [0,31]
packed = (q[:, 0].astype(np.uint32) << 10) | (q[:, 1].astype(np.uint32) << 5) | q[:, 2]
counts = np.bincount(packed, minlength=1)
dominant_bin = int(np.argmax(counts))
# Reconstruct 5-bit channels and shift back to 8-bit (centered in bin).
r = ((dominant_bin >> 10) & 0x1F) << 3
g = ((dominant_bin >> 5) & 0x1F) << 3
b = (dominant_bin & 0x1F) << 3
return (r, g, b)
@@ -35,12 +35,17 @@ class BetterCamCaptureStream(CaptureStream):
except ImportError:
raise RuntimeError("BetterCam not installed. Install with: pip install bettercam")
# Clear global camera cache for fresh DXGI state
try:
self._bettercam.__factory.clean_up()
except Exception as e:
logger.debug("BetterCam factory cleanup on init: %s", e)
pass
# Clear global camera cache for fresh DXGI state.
# NOTE: ``self._bettercam.__factory`` is name-mangled by Python to
# ``self._bettercam._BetterCamCaptureStream__factory`` because the
# access appears inside a class body, which silently AttributeErrors.
# Use string-based getattr to bypass mangling.
_factory = getattr(self._bettercam, "__factory", None)
if _factory is not None:
try:
_factory.clean_up()
except Exception as e:
logger.debug("BetterCam factory cleanup on init failed", error=str(e))
self._camera = self._bettercam.create(
output_idx=self.display_index,
@@ -71,11 +76,12 @@ class BetterCamCaptureStream(CaptureStream):
self._camera = None
if self._bettercam:
try:
self._bettercam.__factory.clean_up()
except Exception as e:
logger.debug("BetterCam factory cleanup on teardown: %s", e)
pass
_factory = getattr(self._bettercam, "__factory", None)
if _factory is not None:
try:
_factory.clean_up()
except Exception as e:
logger.debug("BetterCam factory cleanup on teardown failed", error=str(e))
self._initialized = False
logger.info(f"BetterCam capture stream cleaned up (display={self.display_index})")
@@ -109,8 +115,10 @@ class BetterCamCaptureStream(CaptureStream):
return None
logger.debug(
f"BetterCam captured display {self.display_index}: "
f"{frame.shape[1]}x{frame.shape[0]}"
"BetterCam captured frame",
display=self.display_index,
w=frame.shape[1],
h=frame.shape[0],
)
return ScreenCapture(
@@ -35,12 +35,17 @@ class DXcamCaptureStream(CaptureStream):
except ImportError:
raise RuntimeError("DXcam not installed. Install with: pip install dxcam")
# Clear global camera cache for fresh DXGI state
try:
self._dxcam.__factory.clean_up()
except Exception as e:
logger.debug("DXcam factory cleanup on init: %s", e)
pass
# Clear global camera cache for fresh DXGI state.
# NOTE: ``self._dxcam.__factory`` is name-mangled by Python to
# ``self._dxcam._DXcamCaptureStream__factory`` because the access
# appears inside a class body, which silently AttributeErrors.
# Use string-based getattr to bypass mangling.
_factory = getattr(self._dxcam, "__factory", None)
if _factory is not None:
try:
_factory.clean_up()
except Exception as e:
logger.debug("DXcam factory cleanup on init failed", error=str(e))
self._camera = self._dxcam.create(
output_idx=self.display_index,
@@ -69,11 +74,12 @@ class DXcamCaptureStream(CaptureStream):
self._camera = None
if self._dxcam:
try:
self._dxcam.__factory.clean_up()
except Exception as e:
logger.debug("DXcam factory cleanup on teardown: %s", e)
pass
_factory = getattr(self._dxcam, "__factory", None)
if _factory is not None:
try:
_factory.clean_up()
except Exception as e:
logger.debug("DXcam factory cleanup on teardown failed", error=str(e))
self._initialized = False
logger.info(f"DXcam capture stream cleaned up (display={self.display_index})")
@@ -107,8 +113,10 @@ class DXcamCaptureStream(CaptureStream):
return None
logger.debug(
f"DXcam captured display {self.display_index}: "
f"{frame.shape[1]}x{frame.shape[0]}"
"DXcam captured frame",
display=self.display_index,
w=frame.shape[1],
h=frame.shape[0],
)
return ScreenCapture(
@@ -5,6 +5,13 @@ from typing import Any, Dict, List, Optional
import mss
import numpy as np
try:
import cv2
_HAS_CV2 = True
except ImportError:
_HAS_CV2 = False
from ledgrab.core.capture_engines.base import (
CaptureEngine,
CaptureStream,
@@ -15,6 +22,13 @@ from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rat
logger = get_logger(__name__)
# Rotating RGB output pool: keeps prior frame references stable for any
# consumer still reading them while a new frame is written.
_RGB_POOL_SIZE = 3
# Number of bytes from .raw to hash for change detection (cheap pre-check
# that avoids the full BGRA→RGB conversion when the screen is idle).
_CHANGE_DETECT_BYTES = 256
class MSSCaptureStream(CaptureStream):
"""MSS capture stream for a specific display."""
@@ -22,6 +36,12 @@ class MSSCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._sct = None
# Pre-allocated RGB destination pool — avoids per-frame allocation.
self._rgb_pool: list = [None] * _RGB_POOL_SIZE
self._rgb_idx: int = 0
self._rgb_shape: tuple = (0, 0)
# Cheap hash of the previous .raw bytes, for change detection.
self._prev_hash: Optional[int] = None
def initialize(self) -> None:
try:
@@ -36,6 +56,7 @@ class MSSCaptureStream(CaptureStream):
self._sct.close()
self._sct = None
self._initialized = False
self._prev_hash = None
logger.info(f"MSS capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]:
@@ -55,18 +76,51 @@ class MSSCaptureStream(CaptureStream):
monitor = self._sct.monitors[monitor_index]
screenshot = self._sct.grab(monitor)
# Direct bytes→numpy (skips PIL intermediate object)
img_array = np.frombuffer(
screenshot.rgb,
dtype=np.uint8,
).reshape(screenshot.height, screenshot.width, 3)
# Cheap change detection: hash a small slice of the raw BGRA
# buffer. ~256 bytes is enough to differentiate any cursor/pixel
# change. Skips the BGRA→RGB conversion when nothing changed
# (common on idle desktops). DXcam/BetterCam return None in this
# case natively; mss does not, so we add it here.
raw = screenshot.raw
sample = bytes(raw[:_CHANGE_DETECT_BYTES])
cur_hash = hash(sample)
if cur_hash == self._prev_hash:
return None
self._prev_hash = cur_hash
height = screenshot.height
width = screenshot.width
# Reshape .raw (BGRA) — zero-copy view over the screenshot's buffer.
# ``screenshot.rgb`` (used previously) is a pure-Python BGRA→RGB
# rebuild costing ~6 MB/frame at 1080p in the slowest possible
# way. cv2.cvtColor is SIMD and writes directly into our pool.
bgra = np.frombuffer(raw, dtype=np.uint8).reshape(height, width, 4)
if self._rgb_shape != (height, width):
for i in range(_RGB_POOL_SIZE):
self._rgb_pool[i] = np.empty((height, width, 3), dtype=np.uint8)
self._rgb_shape = (height, width)
dst = self._rgb_pool[self._rgb_idx]
self._rgb_idx = (self._rgb_idx + 1) % _RGB_POOL_SIZE
if _HAS_CV2:
cv2.cvtColor(bgra, cv2.COLOR_BGRA2RGB, dst=dst)
else:
dst[..., 0] = bgra[..., 2]
dst[..., 1] = bgra[..., 1]
dst[..., 2] = bgra[..., 0]
logger.debug(
f"MSS captured display {self.display_index}: {monitor['width']}x{monitor['height']}"
"MSS captured frame",
display=self.display_index,
w=monitor["width"],
h=monitor["height"],
)
return ScreenCapture(
image=img_array,
image=dst,
width=monitor["width"],
height=monitor["height"],
display_index=self.display_index,
@@ -5,6 +5,14 @@ import sys
import threading
from typing import Any, Dict, List, Optional
import numpy as np
try:
import cv2
_HAS_CV2 = True
except ImportError:
_HAS_CV2 = False
from ledgrab.core.capture_engines.base import (
CaptureEngine,
@@ -16,6 +24,10 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__)
# 3-slot rotating output buffer pool: ensures the consumer always has a stable
# RGB array reference (the underlying WGC native buffer is reused per frame).
_RGB_POOL_SIZE = 3
class WGCCaptureStream(CaptureStream):
"""WGC capture stream for a specific display."""
@@ -29,6 +41,11 @@ class WGCCaptureStream(CaptureStream):
self._frame_event = threading.Event()
self._closed_event = threading.Event()
self._frame_lock = threading.Lock()
# Pre-allocated RGB destination buffers (rotated to keep prior frames
# stable for any consumer still reading the previous reference).
self._rgb_pool: list = [None] * _RGB_POOL_SIZE
self._rgb_idx: int = 0
self._rgb_shape: tuple = (0, 0)
def initialize(self) -> None:
if self._wgc is None:
@@ -66,10 +83,33 @@ class WGCCaptureStream(CaptureStream):
width = frame.width
height = frame.height
# WGC provides BGRA format, convert to RGB
# Fancy indexing creates a new contiguous array — no .copy() needed
# WGC provides BGRA. ``frame_buffer`` is a view over the
# native side's reusable buffer — must copy out before
# returning. Use a 3-slot rotating pool of pre-allocated
# RGB buffers + cv2.cvtColor (SIMD) instead of numpy fancy
# indexing. Fancy indexing would allocate ~width*height*3
# bytes per frame (≈480 MB/s at 1080p60); the pool allocates
# 3 buffers total and reuses them.
frame_array = frame_buffer.reshape((height, width, 4))
frame_rgb = frame_array[:, :, [2, 1, 0]]
if self._rgb_shape != (height, width):
for i in range(_RGB_POOL_SIZE):
self._rgb_pool[i] = np.empty((height, width, 3), dtype=np.uint8)
self._rgb_shape = (height, width)
dst = self._rgb_pool[self._rgb_idx]
self._rgb_idx = (self._rgb_idx + 1) % _RGB_POOL_SIZE
if _HAS_CV2:
cv2.cvtColor(frame_array, cv2.COLOR_BGRA2RGB, dst=dst)
frame_rgb = dst
else:
# Fallback: per-channel copy is still 2× faster than
# fancy-index allocation because it writes in-place.
dst[..., 0] = frame_array[..., 2]
dst[..., 1] = frame_array[..., 1]
dst[..., 2] = frame_array[..., 0]
frame_rgb = dst
with self._frame_lock:
self._latest_frame = frame_rgb
@@ -153,8 +193,10 @@ class WGCCaptureStream(CaptureStream):
self._cleanup_internal()
self._initialized = False
# Force garbage collection to release COM objects
gc.collect()
# Gen-0 collect is enough to release recently-allocated COM
# references and avoids the multi-hundred-ms full-heap pause
# ``gc.collect()`` would cause on a heap full of frame ndarrays.
gc.collect(0)
logger.info(f"WGC capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]:
@@ -173,7 +215,10 @@ class WGCCaptureStream(CaptureStream):
self._frame_event.clear()
logger.debug(
f"WGC captured display {self.display_index}: " f"{frame.shape[1]}x{frame.shape[0]}"
"WGC captured frame",
display=self.display_index,
w=frame.shape[1],
h=frame.shape[0],
)
return ScreenCapture(
+1 -19
View File
@@ -24,6 +24,7 @@ import numpy as np
from ledgrab.core.devices.ble_protocols import BLEProtocol, get_protocol
from ledgrab.core.devices.ble_transport import make_transport
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -63,25 +64,6 @@ def _strip_ble_scheme(url: str) -> str:
return url.strip("/")
def _average_color(pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> Tuple[int, int, int]:
"""Reduce an N-pixel strip to one average RGB."""
if isinstance(pixels, np.ndarray):
if pixels.size == 0:
return (0, 0, 0)
arr = pixels.reshape(-1, 3) if pixels.ndim > 1 else pixels[:3].reshape(1, 3)
mean = arr.mean(axis=0)
return int(mean[0]), int(mean[1]), int(mean[2])
if not pixels:
return (0, 0, 0)
total_r = total_g = total_b = 0
for r, g, b in pixels:
total_r += r
total_g += g
total_b += b
n = len(pixels)
return total_r // n, total_g // n, total_b // n
class BLEClient(LEDClient):
"""LED client for BLE controllers speaking one of the registered protocols.
@@ -0,0 +1,221 @@
"""Standalone DDP (Distributed Display Protocol) LEDClient.
Wraps the low-level ``DDPClient`` transport from ``ddp_client.py`` to expose
DDP as a first-class device type. Any receiver that speaks DDP Pixelblaze,
ESPixelStick, xLights/Falcon endpoints, generic DDP firmware can be driven
through this client without WLED in the path.
URL scheme: ``ddp://host[:port]`` or a bare ``host[:port]``. Default port 4048.
"""
from __future__ import annotations
import asyncio
import socket
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse
import numpy as np
from ledgrab.core.devices.ddp_client import DDPClient
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.utils import get_logger
logger = get_logger(__name__)
DEFAULT_DDP_PORT = 4048
DEFAULT_DESTINATION_ID = 0x01 # 1 = display
DEFAULT_COLOR_ORDER = 1 # 1 = RGB (no reorder)
def parse_ddp_url(url: str) -> Tuple[str, int]:
"""Parse a DDP URL into ``(host, port)``.
Accepted forms:
``ddp://192.168.1.50`` ("192.168.1.50", 4048)
``ddp://192.168.1.50:4048`` ("192.168.1.50", 4048)
``192.168.1.50`` ("192.168.1.50", 4048)
``192.168.1.50:4048`` ("192.168.1.50", 4048)
``ddp://[fe80::1]:4048`` ("fe80::1", 4048)
"""
if not url:
raise ValueError("DDP URL is empty")
raw = url.strip()
if "://" in raw:
parsed = urlparse(raw)
host = parsed.hostname or ""
port = parsed.port or DEFAULT_DDP_PORT
else:
# Bare ``host`` or ``host:port`` — wrap into a URL so urlparse handles IPv6.
parsed = urlparse(f"ddp://{raw}")
host = parsed.hostname or ""
port = parsed.port or DEFAULT_DDP_PORT
if not host:
raise ValueError(f"DDP URL has no host: {url!r}")
return host, port
class DDPLEDClient(LEDClient):
"""LEDClient for generic DDP receivers.
Designed for the streaming hot loop: ``send_pixels_fast`` is a synchronous
fire-and-forget UDP push that delegates to the pre-allocated DDP transport.
"""
def __init__(
self,
url: str,
led_count: int = 0,
*,
rgbw: bool = False,
port: Optional[int] = None,
destination_id: int = DEFAULT_DESTINATION_ID,
color_order: int = DEFAULT_COLOR_ORDER,
):
parsed_host, parsed_port = parse_ddp_url(url)
self._host = parsed_host
self._port = port or parsed_port
self._led_count = led_count
self._rgbw = rgbw
self._destination_id = destination_id & 0xFF
self._color_order = color_order
self._ddp: Optional[DDPClient] = None
self._connected = False
@property
def host(self) -> str:
return self._host
@property
def port(self) -> int:
return self._port
@property
def device_led_count(self) -> Optional[int]:
return self._led_count or None
@property
def is_connected(self) -> bool:
return self._connected and self._ddp is not None
async def connect(self) -> bool:
if self._connected and self._ddp is not None:
return True
ddp = DDPClient(self._host, port=self._port, rgbw=self._rgbw)
await ddp.connect()
# Use the BusConfig hook to encode color order across the full strip
# if the user picked something other than RGB.
if self._color_order != DEFAULT_COLOR_ORDER and self._led_count > 0:
from ledgrab.core.devices.ddp_client import BusConfig
ddp.set_buses(
[
BusConfig(
start=0,
length=self._led_count,
color_order=self._color_order,
)
]
)
self._ddp = ddp
self._connected = True
logger.info(
"DDPLEDClient connected to %s:%d (led_count=%d, rgbw=%s, order=%d)",
self._host,
self._port,
self._led_count,
self._rgbw,
self._color_order,
)
return True
async def close(self) -> None:
if self._ddp is not None:
try:
await self._ddp.close()
finally:
self._ddp = None
self._connected = False
@property
def supports_fast_send(self) -> bool:
return True
@staticmethod
def _apply_brightness(pixels: np.ndarray, brightness: int) -> np.ndarray:
if brightness >= 255:
return pixels
if brightness <= 0:
return np.zeros_like(pixels)
# uint16 scratch avoids overflow; integer divide keeps everything in uint8.
return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8)
def _as_numpy(self, pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> np.ndarray:
if isinstance(pixels, np.ndarray):
arr = pixels
else:
arr = np.asarray(pixels, dtype=np.uint8)
if arr.dtype != np.uint8:
arr = arr.astype(np.uint8)
if arr.ndim == 1 and arr.shape[0] % 3 == 0:
arr = arr.reshape(-1, 3)
return arr
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self.is_connected:
raise RuntimeError("DDPLEDClient not connected")
arr = self._apply_brightness(self._as_numpy(pixels), brightness)
assert self._ddp is not None
self._ddp.send_pixels_numpy(arr)
return True
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> None:
if not self.is_connected or self._ddp is None:
raise RuntimeError("DDPLEDClient not connected")
arr = self._apply_brightness(self._as_numpy(pixels), brightness)
self._ddp.send_pixels_numpy(arr)
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""DDP is connectionless UDP — health = host resolves + port reachable.
We don't get an ACK back from DDP receivers, so this is a best-effort
probe: resolve the host (cheap, async) and report online if it succeeds.
Anything more would require sending a frame and waiting for side effects
we can't observe.
"""
now = datetime.now(timezone.utc)
try:
host, _port = parse_ddp_url(url)
except ValueError as exc:
return DeviceHealth(online=False, last_checked=now, error=str(exc))
loop = asyncio.get_running_loop()
try:
start = loop.time()
await loop.getaddrinfo(host, None, type=socket.SOCK_DGRAM)
latency_ms = (loop.time() - start) * 1000.0
except (socket.gaierror, OSError) as exc:
return DeviceHealth(
online=False,
last_checked=now,
error=f"DNS resolution failed for {host}: {exc}",
)
return DeviceHealth(
online=True,
latency_ms=latency_ms,
last_checked=now,
)
@@ -0,0 +1,68 @@
"""DDP device provider — standalone UDP target for any DDP-speaking receiver."""
from __future__ import annotations
from typing import TYPE_CHECKING, List
from ledgrab.core.devices.ddp_led_client import DDPLEDClient, parse_ddp_url
from ledgrab.utils.net_classify import validate_lan_host
from ledgrab.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
ProviderDeps,
)
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.devices.device_config import DDPConfig
logger = get_logger(__name__)
class DDPDeviceProvider(LEDDeviceProvider):
"""Provider for generic DDP receivers (Pixelblaze, ESPixelStick, Falcon, …).
DDP has no native discovery protocol callers must supply a manual IP/host.
LED count is also user-supplied since DDP receivers don't expose a metadata
channel back to the sender.
"""
@property
def device_type(self) -> str:
return "ddp"
@property
def capabilities(self) -> set:
# No power_control / brightness_control: DDP receivers don't define a
# standard reply channel, so we cannot read back state. Software
# brightness is still applied client-side before the frame is sent.
return {"manual_led_count", "health_check"}
def create_client(self, config: "DDPConfig", *, deps: ProviderDeps) -> LEDClient:
return DDPLEDClient(
config.device_url,
led_count=config.led_count,
rgbw=config.rgbw,
port=config.ddp_port or None,
destination_id=config.ddp_destination_id,
color_order=config.ddp_color_order,
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await DDPLEDClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate URL parses cleanly. DDP receivers don't report LED count."""
try:
host, port = parse_ddp_url(url)
except ValueError as exc:
raise ValueError(f"Invalid DDP URL: {exc}") from exc
validate_lan_host(host)
logger.info("DDP device URL validated: host=%s port=%d", host, port)
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""DDP has no native discovery — returns empty list."""
return []
@@ -25,6 +25,21 @@ class WLEDConfig(BaseDeviceConfig):
use_ddp: bool = False
@dataclass(frozen=True)
class DDPConfig(BaseDeviceConfig):
"""Standalone DDP receiver (Pixelblaze, ESPixelStick, Falcon, …).
``ddp_port`` of 0 means "use the protocol default" (4048). ``ddp_color_order``
follows the WLED enum (0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR). Most
non-WLED DDP receivers expect raw RGB (1).
"""
device_type: Literal["ddp"] = "ddp"
ddp_port: int = 0
ddp_destination_id: int = 1
ddp_color_order: int = 1
@dataclass(frozen=True)
class AdalightConfig(BaseDeviceConfig):
device_type: Literal["adalight"] = "adalight"
@@ -61,6 +76,80 @@ class HueConfig(BaseDeviceConfig):
hue_entertainment_group_id: str = ""
@dataclass(frozen=True)
class YeelightConfig(BaseDeviceConfig):
"""Yeelight (Xiaomi) LAN bulb / lightstrip.
``yeelight_min_interval_ms`` rate-limits outbound commands client-side
so the bulb's per-second cap isn't exceeded. Default 500 ms 2 Hz.
"""
device_type: Literal["yeelight"] = "yeelight"
yeelight_min_interval_ms: int = 500
@dataclass(frozen=True)
class WiZConfig(BaseDeviceConfig):
"""WiZ Connected (Philips budget-tier) UDP LAN bulb.
``wiz_min_interval_ms`` is a client-side rate gate. WiZ tolerates much
higher rates than Yeelight (UDP, no ack) so the default is 50 ms 20 Hz.
"""
device_type: Literal["wiz"] = "wiz"
wiz_min_interval_ms: int = 50
@dataclass(frozen=True)
class LIFXConfig(BaseDeviceConfig):
"""LIFX LAN bulb / lightstrip.
LIFX recommends 20 commands/sec per device. ``lifx_min_interval_ms``
defaults to 50 ms so we stay just under that ceiling.
"""
device_type: Literal["lifx"] = "lifx"
lifx_min_interval_ms: int = 50
@dataclass(frozen=True)
class GoveeConfig(BaseDeviceConfig):
"""Govee Wi-Fi bulb / ambient kit reachable via the LAN API.
Each device needs "LAN Control" toggled ON in the Govee Home app before
it answers discovery or commands. UDP fire-and-forget tolerates ~20 Hz.
"""
device_type: Literal["govee"] = "govee"
govee_min_interval_ms: int = 50
@dataclass(frozen=True)
class OPCConfig(BaseDeviceConfig):
"""Open Pixel Control receiver (Fadecandy, OPC bridges, hobbyist drivers).
``opc_channel`` of 0 broadcasts to every channel on the OPC server;
1-255 addresses a specific output on multi-channel servers.
"""
device_type: Literal["opc"] = "opc"
opc_channel: int = 0
@dataclass(frozen=True)
class NanoleafConfig(BaseDeviceConfig):
"""Nanoleaf controller (Light Panels / Canvas / Shapes / Lines / Elements).
``nanoleaf_token`` is the long-lived auth token returned by the pairing
handshake. Without it the controller rejects every state-mutating call,
so device creation should be preceded by a successful pairing flow.
"""
device_type: Literal["nanoleaf"] = "nanoleaf"
nanoleaf_token: str = ""
nanoleaf_min_interval_ms: int = 100
@dataclass(frozen=True)
class SPIConfig(BaseDeviceConfig):
device_type: Literal["spi"] = "spi"
@@ -115,6 +204,7 @@ class DemoConfig(BaseDeviceConfig):
@dataclass(frozen=True)
class MQTTConfig(BaseDeviceConfig):
device_type: Literal["mqtt"] = "mqtt"
mqtt_source_id: str = "" # references an MQTTSource (multi-broker)
@dataclass(frozen=True)
@@ -129,6 +219,13 @@ class USBHIDConfig(BaseDeviceConfig):
DeviceConfig = Union[
WLEDConfig,
DDPConfig,
YeelightConfig,
WiZConfig,
LIFXConfig,
GoveeConfig,
OPCConfig,
NanoleafConfig,
AdalightConfig,
AmbiLEDConfig,
DMXConfig,
@@ -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
@@ -0,0 +1,398 @@
"""Govee LAN API LED client.
Govee opened a local LAN API in 2023 for its Wi-Fi smart bulbs and
ambient-light kits. Discovery is multicast on ``239.255.255.250:4001``;
control commands go unicast to the bulb's port ``4003``; the bulb sends
responses on port ``4002`` (which we don't listen on for ambient streaming).
Prerequisite for every device: the user must toggle "LAN Control" ON in the
Govee Home app (Device LAN Control). Devices with LAN Control disabled
do not respond to discovery or commands. The UI hint copy reminds the user.
URL scheme: ``govee://<host>`` or bare ``<host>``. Port 4003 is fixed.
Reference: https://app-h5.govee.com/user-manual/wlan-guide
"""
from __future__ import annotations
import asyncio
import json
import socket
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse
import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.utils import get_logger
logger = get_logger(__name__)
GOVEE_DISCOVERY_PORT = 4001
GOVEE_RESPONSE_PORT = 4002
GOVEE_CONTROL_PORT = 4003
GOVEE_MULTICAST_GROUP = "239.255.255.250"
DEFAULT_MIN_INTERVAL_S = 0.05 # ~20 Hz; UDP fire-and-forget, no ack
_DISCOVERY_REQUEST = json.dumps(
{"msg": {"cmd": "scan", "data": {"account_topic": "reserve"}}}
).encode("utf-8")
# Govee mandates listening on port 4002 for scan replies; the OS only lets
# one process / socket own it at a time. Serialize concurrent discover
# calls (e.g. two browser tabs hitting /discover at once) so the second
# caller waits its turn instead of getting an empty result.
_DISCOVERY_LOCK: asyncio.Lock | None = None
def _discovery_lock() -> asyncio.Lock:
"""Return a module-level asyncio.Lock, created lazily.
Can't construct at import-time because asyncio.Lock binds to the
running event loop and module import may precede loop creation.
"""
global _DISCOVERY_LOCK
if _DISCOVERY_LOCK is None:
_DISCOVERY_LOCK = asyncio.Lock()
return _DISCOVERY_LOCK
def parse_govee_url(url: str) -> str:
"""Pull the host out of ``govee://host`` or bare ``host``."""
if not url:
raise ValueError("Govee URL is empty")
raw = url.strip()
if "://" in raw:
parsed = urlparse(raw)
host = parsed.hostname or ""
else:
parsed = urlparse(f"govee://{raw}")
host = parsed.hostname or ""
if not host:
raise ValueError(f"Govee URL has no host: {url!r}")
return host
class _GoveeProtocol(asyncio.DatagramProtocol):
"""Write-only datagram protocol. Bulb replies (on 4002) are not collected here."""
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
pass
def error_received(self, exc):
logger.debug("Govee UDP error: %s", exc)
class GoveeClient(LEDClient):
"""LEDClient for a single Govee LAN-enabled bulb / ambient kit."""
def __init__(
self,
url: str,
led_count: int = 1,
*,
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
):
self._host = parse_govee_url(url)
self._port = GOVEE_CONTROL_PORT
self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s)
self._transport: Optional[asyncio.DatagramTransport] = None
self._protocol: Optional[_GoveeProtocol] = None
self._connected = False
self._next_tx_at: float = 0.0
@property
def host(self) -> str:
return self._host
@property
def port(self) -> int:
return self._port
@property
def is_connected(self) -> bool:
return self._connected and self._transport is not None
@property
def device_led_count(self) -> Optional[int]:
return self._led_count or None
async def connect(self) -> bool:
if self._connected and self._transport is not None:
return True
loop = asyncio.get_running_loop()
try:
transport, protocol = await loop.create_datagram_endpoint(
_GoveeProtocol, remote_addr=(self._host, self._port)
)
except OSError as exc:
raise RuntimeError(f"Failed to open UDP to Govee at {self._host}: {exc}") from exc
self._transport = transport
self._protocol = protocol # type: ignore[assignment]
self._connected = True
logger.info("GoveeClient connected to %s:%d", self._host, self._port)
return True
async def close(self) -> None:
if self._transport is not None:
try:
self._transport.close()
except OSError:
pass
self._transport = None
self._protocol = None
self._connected = False
def _send_json(self, payload: dict) -> None:
assert self._transport is not None
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
self._transport.sendto(raw)
@staticmethod
def _build_color_payload(r: int, g: int, b: int) -> dict:
"""Govee colorwc command. ``colorTemInKelvin=0`` selects pure RGB."""
return {
"msg": {
"cmd": "colorwc",
"data": {
"color": {"r": r & 0xFF, "g": g & 0xFF, "b": b & 0xFF},
"colorTemInKelvin": 0,
},
}
}
@staticmethod
def _build_brightness_payload(value_0_100: int) -> dict:
return {
"msg": {
"cmd": "brightness",
"data": {"value": max(1, min(100, value_0_100))},
}
}
@staticmethod
def _build_power_payload(on: bool) -> dict:
return {"msg": {"cmd": "turn", "data": {"value": 1 if on else 0}}}
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
"""Average the strip → colorwc with the resulting RGB."""
if not self.is_connected:
raise RuntimeError("GoveeClient not connected")
now = time.monotonic()
if now < self._next_tx_at:
return True
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
self._send_json(self._build_color_payload(r, g, b))
self._next_tx_at = now + self._min_interval_s
return True
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> None:
"""Synchronous variant for the hot loop."""
if not self.is_connected or self._transport is None:
raise RuntimeError("GoveeClient not connected")
now = time.monotonic()
if now < self._next_tx_at:
return
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
self._send_json(self._build_color_payload(r, g, b))
self._next_tx_at = now + self._min_interval_s
@property
def supports_fast_send(self) -> bool:
return True
async def set_color(self, r: int, g: int, b: int) -> None:
if not self.is_connected:
raise RuntimeError("GoveeClient not connected")
self._send_json(self._build_color_payload(r, g, b))
async def set_brightness(self, brightness_0_100: int) -> None:
if not self.is_connected:
raise RuntimeError("GoveeClient not connected")
self._send_json(self._build_brightness_payload(brightness_0_100))
async def set_power(self, on: bool) -> None:
if not self.is_connected:
raise RuntimeError("GoveeClient not connected")
self._send_json(self._build_power_payload(on))
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Send devStatus and wait briefly for a reply on port 4002.
Govee bulbs send responses to whatever port the request came from
when using ``connected`` UDP, so we bind to a random ephemeral port
and accept any reply. Health is best-effort a silent bulb may
still be online (it just hasn't toggled LAN Control on yet).
"""
now = datetime.now(timezone.utc)
try:
host = parse_govee_url(url)
except ValueError as exc:
return DeviceHealth(online=False, last_checked=now, error=str(exc))
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setblocking(False)
try:
sock.bind(("", 0))
probe = json.dumps({"msg": {"cmd": "devStatus", "data": {}}}).encode("utf-8")
start = loop.time()
await loop.sock_sendto(sock, probe, (host, GOVEE_CONTROL_PORT))
try:
await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=1.5)
except asyncio.TimeoutError:
return DeviceHealth(
online=False,
last_checked=now,
error=(
f"No Govee reply from {host} within 1.5s — is "
"LAN Control enabled in the Govee Home app?"
),
)
latency_ms = (loop.time() - start) * 1000.0
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
except OSError as exc:
return DeviceHealth(
online=False,
last_checked=now,
error=f"Govee probe failed for {host}: {exc}",
)
finally:
sock.close()
# ============================================================================
# Multicast discovery
# ============================================================================
def _parse_scan_reply(raw: bytes) -> Optional[dict]:
"""Parse a Govee scan reply into a flat metadata dict.
Govee sends ``{"msg": {"cmd": "scan", "data": {"ip": ..., "device": ...,
"sku": ..., "wifiVersionSoft": ..., ...}}}``. Returns the inner ``data``
dict, or ``None`` for malformed packets.
"""
try:
payload = json.loads(raw.decode("utf-8", errors="replace"))
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(payload, dict):
return None
msg = payload.get("msg")
if not isinstance(msg, dict):
return None
if msg.get("cmd") != "scan":
return None
data = msg.get("data")
if not isinstance(data, dict):
return None
return data
async def discover_govee_devices(timeout: float = 2.0) -> List[dict]:
"""Multicast a scan request and collect Govee scan replies.
Returns a list of ``{"ip": ..., "device": ..., "sku": ..., "version": ...}``
dicts. Multicast / receive failures (no network, firewall, no LAN-enabled
bulbs) yield an empty list rather than raising.
Concurrent invocations are serialized via a module-level
``asyncio.Lock`` so two callers don't race on the protocol-mandated
response port 4002.
"""
async with _discovery_lock():
return await _discover_govee_devices_locked(timeout)
async def _discover_govee_devices_locked(timeout: float) -> List[dict]:
loop = asyncio.get_running_loop()
# We bind a separate socket to the response port (4002). The
# _DISCOVERY_LOCK serializes our own scans; the bind can still fail
# if a non-LedGrab Govee tool on the same host owns 4002.
recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
recv_sock.setblocking(False)
send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
send_sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
send_sock.setblocking(False)
try:
try:
recv_sock.bind(("", GOVEE_RESPONSE_PORT))
except OSError as exc:
logger.warning(
"Govee discovery: cannot bind port %d (%s). Another Govee "
"tool on this host may own the port; close it and retry.",
GOVEE_RESPONSE_PORT,
exc,
)
return []
send_sock.bind(("", 0))
await loop.sock_sendto(
send_sock, _DISCOVERY_REQUEST, (GOVEE_MULTICAST_GROUP, GOVEE_DISCOVERY_PORT)
)
results: list[dict] = []
seen_ips: set[str] = set()
deadline = loop.time() + timeout
while True:
remaining = deadline - loop.time()
if remaining <= 0:
break
try:
raw, addr = await asyncio.wait_for(
loop.sock_recvfrom(recv_sock, 4096),
timeout=remaining,
)
except asyncio.TimeoutError:
break
data = _parse_scan_reply(raw)
if not data:
continue
ip = data.get("ip") or addr[0]
if not ip or ip in seen_ips:
continue
seen_ips.add(ip)
results.append(
{
"ip": ip,
"device": data.get("device", ""),
"sku": data.get("sku", ""),
"version": data.get("wifiVersionSoft", "") or data.get("bleVersionSoft", ""),
}
)
return results
finally:
recv_sock.close()
send_sock.close()
@@ -0,0 +1,97 @@
"""Govee LAN device provider — LAN-discoverable Govee Wi-Fi bulbs and kits."""
from __future__ import annotations
from typing import TYPE_CHECKING, List
from ledgrab.core.devices.govee_client import (
GoveeClient,
discover_govee_devices,
parse_govee_url,
)
from ledgrab.utils.net_classify import validate_lan_host
from ledgrab.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
ProviderDeps,
)
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.devices.device_config import GoveeConfig
logger = get_logger(__name__)
class GoveeDeviceProvider(LEDDeviceProvider):
"""Provider for Govee LAN-enabled Wi-Fi smart bulbs and ambient kits.
Single-pixel adapter (averaging shape). Note that **per-device LAN
Control toggle must be enabled in the Govee Home app** before the bulb
will respond to discovery or commands the UI hint copy reminds users.
"""
@property
def device_type(self) -> str:
return "govee"
@property
def capabilities(self) -> set:
return {
"manual_led_count",
"power_control",
"brightness_control",
"static_color",
"health_check",
"single_pixel",
}
def create_client(self, config: "GoveeConfig", *, deps: ProviderDeps) -> LEDClient:
return GoveeClient(
config.device_url,
led_count=config.led_count,
min_interval_s=max(0.0, config.govee_min_interval_ms / 1000.0),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await GoveeClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
try:
host = parse_govee_url(url)
except ValueError as exc:
raise ValueError(f"Invalid Govee URL: {exc}") from exc
validate_lan_host(host)
logger.info("Govee device URL validated: host=%s", host)
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
try:
bulbs = await discover_govee_devices(timeout=min(timeout, 5.0))
except (OSError, RuntimeError) as exc:
logger.warning("Govee discovery failed: %s", exc)
return []
results: List[DiscoveredDevice] = []
for bulb in bulbs:
ip = bulb.get("ip", "")
if not ip:
continue
url = f"govee://{ip}"
sku = bulb.get("sku") or "Govee"
mac_like = bulb.get("device", "")
results.append(
DiscoveredDevice(
name=f"Govee {sku}".strip(),
url=url,
device_type="govee",
ip=ip,
mac=mac_like,
led_count=None,
version=bulb.get("version") or None,
)
)
logger.info("Govee multicast scan found %d device(s)", len(results))
return results
@@ -17,6 +17,7 @@ class ProviderDeps:
"""Runtime dependencies injected into every provider.create_client() call."""
device_store: Optional["DeviceStore"] = None
mqtt_manager: Optional[object] = None # MQTTManager (avoid circular import)
@dataclass
@@ -36,6 +37,15 @@ class DeviceHealth:
error: Optional[str] = None
class PairingNotReady(Exception):
"""Raised by ``pair_device`` when the user hasn't performed the physical
pairing action yet (or it timed out before the device acknowledged).
Distinct from generic exceptions so the route handler can return a 409
instead of a 500 the UI shows a retry prompt rather than a hard error.
"""
@dataclass
class DiscoveredDevice:
"""A device found via network discovery."""
@@ -215,6 +225,30 @@ class LEDDeviceProvider(ABC):
"""
return []
async def pair_device(self, url: str) -> Dict[str, object]:
"""Run a pairing handshake against the device at ``url``.
Implementations expect the user to have just performed the device's
physical pairing action (Nanoleaf: hold power 5s; Hue: press the
link button; Twinkly: enter Network setup mode) before calling.
Returns:
A dict of provider-specific fields that the caller should merge
into the subsequent ``create_device`` payload e.g.
``{"nanoleaf_token": "abc..."}`` or ``{"tuya_local_key": "..."}``.
Raises:
PairingNotReady when the user hasn't performed the physical
action yet (the caller surfaces this as a retryable 409 so
the UI can show "Press the button now, then try again").
ValueError on a fundamentally invalid URL or device.
Default: raises ``NotImplementedError`` so a missing implementation
on a ``requires_pairing`` provider fails loud at request time
rather than silently returning an empty dict.
"""
raise NotImplementedError(f"{self.device_type} doesn't support pairing")
async def get_brightness(self, url: str) -> int:
"""Get device brightness (0-255). Override if capabilities include brightness_control."""
raise NotImplementedError
@@ -301,6 +335,10 @@ def _register_builtin_providers():
register_provider(AdalightDeviceProvider())
from ledgrab.core.devices.ddp_provider import DDPDeviceProvider
register_provider(DDPDeviceProvider())
from ledgrab.core.devices.ambiled_provider import AmbiLEDDeviceProvider
register_provider(AmbiLEDDeviceProvider())
@@ -333,6 +371,30 @@ def _register_builtin_providers():
register_provider(HueDeviceProvider())
from ledgrab.core.devices.yeelight_provider import YeelightDeviceProvider
register_provider(YeelightDeviceProvider())
from ledgrab.core.devices.wiz_provider import WiZDeviceProvider
register_provider(WiZDeviceProvider())
from ledgrab.core.devices.lifx_provider import LIFXDeviceProvider
register_provider(LIFXDeviceProvider())
from ledgrab.core.devices.govee_provider import GoveeDeviceProvider
register_provider(GoveeDeviceProvider())
from ledgrab.core.devices.opc_provider import OPCDeviceProvider
register_provider(OPCDeviceProvider())
from ledgrab.core.devices.nanoleaf_provider import NanoleafDeviceProvider
register_provider(NanoleafDeviceProvider())
# BLE support is optional — only register the provider if the ``bleak``
# extra is installed. Importing the provider itself is safe (it doesn't
# import bleak at module load), but we still want a clean skip on
@@ -0,0 +1,405 @@
"""LIFX LAN LED client.
LIFX bulbs and lightstrips accept a binary UDP protocol on port 56700.
Every packet has a 36-byte header (frame + frame-address + protocol-header)
followed by a type-specific payload. Colors are HSBK 16-bit per channel.
URL scheme: ``lifx://<host>[:port]`` or bare ``<host>[:port]``. Default port 56700.
LIFX bulbs are reachable two ways:
* Unicast set the ``target`` field to the bulb's 48-bit MAC.
* Broadcast set ``target`` to all zeros and ``tagged=1``; all bulbs on
the subnet act on the message. We use this for the SetColor hot path
so we don't have to learn the MAC of every device a user owns.
Reference: https://lan.developer.lifx.com/docs/header-description
"""
from __future__ import annotations
import asyncio
import socket
import struct
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse
import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.utils import get_logger
logger = get_logger(__name__)
LIFX_PORT = 56700
DEFAULT_MIN_INTERVAL_S = 0.05 # ~20 Hz — LIFX rate-limit guidance is 20/sec
# Message types we care about
MSG_GET_SERVICE = 2
MSG_STATE_SERVICE = 3
MSG_SET_POWER = 21
MSG_SET_COLOR = 102
# Frame field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024
_FRAME_TAGGED = 0x3400
_FRAME_UNTAGGED = 0x1400
_SOURCE_ID = 0x4C474752 # "LGGR" — identifies LedGrab in protocol logs
def parse_lifx_url(url: str) -> Tuple[str, int]:
"""Pull ``(host, port)`` from ``lifx://host[:port]`` or bare ``host[:port]``."""
if not url:
raise ValueError("LIFX URL is empty")
raw = url.strip()
if "://" in raw:
parsed = urlparse(raw)
host = parsed.hostname or ""
port = parsed.port or LIFX_PORT
else:
parsed = urlparse(f"lifx://{raw}")
host = parsed.hostname or ""
port = parsed.port or LIFX_PORT
if not host:
raise ValueError(f"LIFX URL has no host: {url!r}")
return host, port
def rgb_to_hsbk(r: int, g: int, b: int, kelvin: int = 3500) -> Tuple[int, int, int, int]:
"""Convert 8-bit RGB to LIFX 16-bit HSBK.
The ``kelvin`` channel is irrelevant when saturation > 0 (the bulb
interprets it as a hint, not a hard temperature), so we leave it at the
LIFX default of ~3500 K. Outputs are clamped uint16.
"""
r_n = max(0, min(255, r)) / 255.0
g_n = max(0, min(255, g)) / 255.0
b_n = max(0, min(255, b)) / 255.0
c_max = max(r_n, g_n, b_n)
c_min = min(r_n, g_n, b_n)
delta = c_max - c_min
# Hue (0-360 degrees → 0-65535)
if delta == 0:
h = 0.0
elif c_max == r_n:
h = 60.0 * (((g_n - b_n) / delta) % 6)
elif c_max == g_n:
h = 60.0 * (((b_n - r_n) / delta) + 2)
else:
h = 60.0 * (((r_n - g_n) / delta) + 4)
hue_u16 = int((h / 360.0) * 65535) & 0xFFFF
# Saturation (0-1 → 0-65535)
sat_u16 = 0 if c_max == 0 else int((delta / c_max) * 65535) & 0xFFFF
# Brightness (0-1 → 0-65535)
bri_u16 = int(c_max * 65535) & 0xFFFF
kelvin_u16 = max(2500, min(9000, kelvin)) & 0xFFFF
return hue_u16, sat_u16, bri_u16, kelvin_u16
def _build_packet(
*,
msg_type: int,
payload: bytes,
target_mac: bytes = b"\x00\x00\x00\x00\x00\x00",
sequence: int = 0,
res_required: bool = False,
ack_required: bool = False,
tagged: bool = False,
) -> bytes:
"""Pack a LIFX packet: 36-byte header + payload.
See https://lan.developer.lifx.com/docs/header-description for the
bit-level field layout. We construct the three sub-headers separately
so the magic numbers are scoped to the fields they belong to.
"""
size = 36 + len(payload)
# Frame header (8 bytes): size(2) | origin/tagged/addressable/protocol(2) | source(4)
frame_field = _FRAME_TAGGED if tagged else _FRAME_UNTAGGED
frame = struct.pack("<HHI", size, frame_field, _SOURCE_ID)
# Frame address (16 bytes): target(8) | reserved(6) | res/ack flags(1) | sequence(1)
flags = (0x01 if res_required else 0) | (0x02 if ack_required else 0)
frame_addr = (
target_mac + b"\x00\x00" + b"\x00\x00\x00\x00\x00\x00" + bytes([flags, sequence & 0xFF])
)
# Protocol header (12 bytes): reserved(8) | type(2) | reserved(2)
proto = b"\x00" * 8 + struct.pack("<HH", msg_type, 0)
return frame + frame_addr + proto + payload
def _build_set_color_payload(h: int, s: int, b: int, k: int, duration_ms: int = 0) -> bytes:
"""SetColor payload: reserved(1) | HSBK(8) | duration(4)."""
return b"\x00" + struct.pack(
"<HHHHI", h & 0xFFFF, s & 0xFFFF, b & 0xFFFF, k & 0xFFFF, duration_ms & 0xFFFFFFFF
)
def _build_set_power_payload(on: bool, duration_ms: int = 0) -> bytes:
"""SetPower payload: level(2) | duration(4). Level is 0 or 65535."""
return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF)
def _parse_state_service_reply(raw: bytes) -> Optional[dict]:
"""Parse a LIFX StateService (discovery) reply.
Returns ``{"mac": "aabbccddeeff", "service": int, "port": int}`` or
``None`` if the payload isn't a StateService.
"""
if len(raw) < 36 + 5:
return None
# Read msg_type from the protocol header at offset 32
msg_type = struct.unpack_from("<H", raw, 32)[0]
if msg_type != MSG_STATE_SERVICE:
return None
# Target MAC at offset 8, 6 bytes are the MAC.
mac_bytes = raw[8:14]
mac = mac_bytes.hex()
# Payload at offset 36: service(1) + port(4)
service, port = struct.unpack_from("<BI", raw, 36)
return {"mac": mac, "service": int(service), "port": int(port)}
class _LIFXProtocol(asyncio.DatagramProtocol):
"""Write-only datagram protocol. Inbound replies dropped silently."""
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
# LIFX bulbs sometimes echo back state on broadcast. We don't need it
# for streaming ambilight — discard.
pass
def error_received(self, exc):
logger.debug("LIFX UDP error: %s", exc)
class LIFXClient(LEDClient):
"""LEDClient for a single LIFX bulb on the LAN."""
def __init__(
self,
url: str,
led_count: int = 1,
*,
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
):
host, port = parse_lifx_url(url)
self._host = host
self._port = port
self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s)
self._transport: Optional[asyncio.DatagramTransport] = None
self._protocol: Optional[_LIFXProtocol] = None
self._connected = False
self._next_tx_at: float = 0.0
self._sequence: int = 0
@property
def host(self) -> str:
return self._host
@property
def port(self) -> int:
return self._port
@property
def is_connected(self) -> bool:
return self._connected and self._transport is not None
@property
def device_led_count(self) -> Optional[int]:
return self._led_count or None
async def connect(self) -> bool:
if self._connected and self._transport is not None:
return True
loop = asyncio.get_running_loop()
try:
transport, protocol = await loop.create_datagram_endpoint(
_LIFXProtocol, remote_addr=(self._host, self._port)
)
except OSError as exc:
raise RuntimeError(f"Failed to open UDP to LIFX at {self._host}: {exc}") from exc
self._transport = transport
self._protocol = protocol # type: ignore[assignment]
self._connected = True
logger.info("LIFXClient connected to %s:%d", self._host, self._port)
return True
async def close(self) -> None:
if self._transport is not None:
try:
self._transport.close()
except OSError:
pass
self._transport = None
self._protocol = None
self._connected = False
def _next_sequence(self) -> int:
self._sequence = (self._sequence + 1) & 0xFF
return self._sequence
def _send(self, msg_type: int, payload: bytes) -> None:
"""Build and send one LIFX packet. Caller must hold an open transport."""
assert self._transport is not None
packet = _build_packet(
msg_type=msg_type,
payload=payload,
sequence=self._next_sequence(),
tagged=True, # broadcast within the unicast UDP socket — bulb still acts on it
)
self._transport.sendto(packet)
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
"""Average the strip → HSBK → SetColor."""
if not self.is_connected:
raise RuntimeError("LIFXClient not connected")
now = time.monotonic()
if now < self._next_tx_at:
return True
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
self._next_tx_at = now + self._min_interval_s
return True
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> None:
"""Synchronous variant — same shape, runs on the hot loop."""
if not self.is_connected or self._transport is None:
raise RuntimeError("LIFXClient not connected")
now = time.monotonic()
if now < self._next_tx_at:
return
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
self._next_tx_at = now + self._min_interval_s
@property
def supports_fast_send(self) -> bool:
return True
async def set_color(self, r: int, g: int, b: int) -> None:
if not self.is_connected:
raise RuntimeError("LIFXClient not connected")
h, s, br, k = rgb_to_hsbk(r, g, b)
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
async def set_power(self, on: bool) -> None:
if not self.is_connected:
raise RuntimeError("LIFXClient not connected")
self._send(MSG_SET_POWER, _build_set_power_payload(on))
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Send GetService and wait briefly for a StateService reply."""
now = datetime.now(timezone.utc)
try:
host, port = parse_lifx_url(url)
except ValueError as exc:
return DeviceHealth(online=False, last_checked=now, error=str(exc))
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setblocking(False)
try:
sock.bind(("", 0))
probe = _build_packet(msg_type=MSG_GET_SERVICE, payload=b"", tagged=True)
start = loop.time()
await loop.sock_sendto(sock, probe, (host, port))
try:
await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=1.5)
except asyncio.TimeoutError:
return DeviceHealth(
online=False,
last_checked=now,
error=f"No LIFX reply from {host}:{port} within 1.5s",
)
latency_ms = (loop.time() - start) * 1000.0
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
except OSError as exc:
return DeviceHealth(
online=False,
last_checked=now,
error=f"LIFX probe failed for {host}: {exc}",
)
finally:
sock.close()
# ============================================================================
# Broadcast discovery
# ============================================================================
async def discover_lifx_bulbs(timeout: float = 2.0) -> List[dict]:
"""Broadcast a GetService probe on the LAN and collect StateService replies.
Returns ``[{"ip": ..., "mac": ..., "port": ...}, ...]``.
"""
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.setblocking(False)
try:
sock.bind(("", 0))
probe = _build_packet(msg_type=MSG_GET_SERVICE, payload=b"", tagged=True)
await loop.sock_sendto(sock, probe, ("255.255.255.255", LIFX_PORT))
results: list[dict] = []
seen_macs: set[str] = set()
deadline = loop.time() + timeout
while True:
remaining = deadline - loop.time()
if remaining <= 0:
break
try:
raw, addr = await asyncio.wait_for(
loop.sock_recvfrom(sock, 4096),
timeout=remaining,
)
except asyncio.TimeoutError:
break
parsed = _parse_state_service_reply(raw)
if not parsed:
continue
mac = parsed["mac"]
if mac in seen_macs:
continue
seen_macs.add(mac)
results.append(
{
"ip": addr[0],
"mac": mac,
"port": parsed["port"],
}
)
return results
finally:
sock.close()
@@ -0,0 +1,94 @@
"""LIFX device provider — LAN-discoverable LIFX smart bulbs."""
from __future__ import annotations
from typing import TYPE_CHECKING, List
from ledgrab.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
ProviderDeps,
)
from ledgrab.core.devices.lifx_client import (
LIFXClient,
discover_lifx_bulbs,
parse_lifx_url,
)
from ledgrab.utils.net_classify import validate_lan_host
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.devices.device_config import LIFXConfig
logger = get_logger(__name__)
class LIFXDeviceProvider(LEDDeviceProvider):
"""Provider for LIFX smart bulbs / lightstrips.
Single-pixel adapter: averages the strip down to one color, encodes as
HSBK (LIFX's native color model), and broadcasts a SetColor packet.
"""
@property
def device_type(self) -> str:
return "lifx"
@property
def capabilities(self) -> set:
return {
"manual_led_count",
"power_control",
"static_color",
"health_check",
"single_pixel",
}
def create_client(self, config: "LIFXConfig", *, deps: ProviderDeps) -> LEDClient:
return LIFXClient(
config.device_url,
led_count=config.led_count,
min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await LIFXClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
try:
host, port = parse_lifx_url(url)
except ValueError as exc:
raise ValueError(f"Invalid LIFX URL: {exc}") from exc
validate_lan_host(host)
logger.info("LIFX device URL validated: host=%s port=%d", host, port)
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
try:
bulbs = await discover_lifx_bulbs(timeout=min(timeout, 5.0))
except (OSError, RuntimeError) as exc:
logger.warning("LIFX discovery failed: %s", exc)
return []
results: List[DiscoveredDevice] = []
for bulb in bulbs:
ip = bulb.get("ip", "")
if not ip:
continue
url = f"lifx://{ip}"
mac = bulb.get("mac", "")
results.append(
DiscoveredDevice(
name=f"LIFX {mac[-6:]}" if mac else "LIFX bulb",
url=url,
device_type="lifx",
ip=ip,
mac=mac,
led_count=None,
version=None,
)
)
logger.info("LIFX broadcast scan found %d bulb(s)", len(results))
return results
+58 -31
View File
@@ -1,7 +1,12 @@
"""MQTT LED client — publishes pixel data to an MQTT topic."""
"""MQTT LED client — publishes pixel data to an MQTT topic on a configured broker.
The client acquires a per-source runtime from :class:`MQTTManager` at
``connect()`` time and releases it at ``close()``. Every device references
an ``mqtt_source_id`` so different devices can talk to different brokers.
"""
import json
from typing import List, Tuple, Union
from typing import List, Optional, Tuple, Union
import numpy as np
@@ -10,23 +15,11 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__)
# Singleton reference — injected from main.py at startup
_mqtt_service = None
def set_mqtt_service(service) -> None:
global _mqtt_service
_mqtt_service = service
def get_mqtt_service():
return _mqtt_service
def parse_mqtt_url(url: str) -> str:
"""Extract topic from an mqtt:// URL.
Format: mqtt://topic/path (broker connection is global via config)
Format: mqtt://topic/path (broker connection is per-source, not in URL)
"""
if url.startswith("mqtt://"):
return url[7:]
@@ -34,36 +27,64 @@ def parse_mqtt_url(url: str) -> str:
class MQTTLEDClient(LEDClient):
"""Publishes JSON pixel data to an MQTT topic via the shared service."""
"""Publishes JSON pixel data to an MQTT topic via an MQTTManager runtime."""
def __init__(self, url: str, led_count: int = 0, **kwargs):
def __init__(
self,
url: str,
led_count: int = 0,
*,
mqtt_manager=None,
mqtt_source_id: str = "",
**kwargs,
):
self._topic = parse_mqtt_url(url)
self._led_count = led_count
self._mqtt_manager = mqtt_manager
self._mqtt_source_id = mqtt_source_id
self._runtime = None
self._connected = False
async def connect(self) -> bool:
svc = _mqtt_service
if svc is None or not svc.is_enabled:
raise ConnectionError("MQTT service not available")
if not svc.is_connected:
raise ConnectionError("MQTT service not connected to broker")
if self._mqtt_manager is None:
raise ConnectionError("MQTT manager not available")
if not self._mqtt_source_id:
raise ConnectionError("Device has no mqtt_source_id configured")
try:
self._runtime = await self._mqtt_manager.acquire(self._mqtt_source_id)
except Exception as e:
raise ConnectionError(f"Failed to acquire MQTT runtime: {e}") from e
if not self._runtime.is_connected:
# Runtime exists but the broker hasn't established the TCP
# connection yet — leave the LED client in a "queued" state so
# publishes get buffered (MQTTRuntime.publish queues on
# disconnect). The runtime will drain when connection is made.
logger.info(
"MQTT LED client %s: runtime acquired but broker not yet connected",
self._mqtt_source_id,
)
self._connected = True
return True
async def close(self) -> None:
if self._runtime is not None and self._mqtt_manager is not None:
try:
await self._mqtt_manager.release(self._mqtt_source_id)
except Exception as e:
logger.warning("Failed to release MQTT runtime %s: %s", self._mqtt_source_id, e)
self._runtime = None
self._connected = False
@property
def is_connected(self) -> bool:
return self._connected and _mqtt_service is not None and _mqtt_service.is_connected
return self._connected and self._runtime is not None and self._runtime.is_connected
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
svc = _mqtt_service
if svc is None or not svc.is_connected:
if self._runtime is None:
return False
if isinstance(pixels, np.ndarray):
@@ -79,7 +100,7 @@ class MQTTLEDClient(LEDClient):
}
)
await svc.publish(self._topic, payload, retain=False, qos=0)
await self._runtime.publish(self._topic, payload, retain=False, qos=0)
return True
@classmethod
@@ -88,16 +109,22 @@ class MQTTLEDClient(LEDClient):
url: str,
http_client,
prev_health=None,
*,
mqtt_manager=None,
mqtt_source_id: Optional[str] = None,
) -> DeviceHealth:
from datetime import datetime, timezone
svc = _mqtt_service
if svc is None or not svc.is_enabled:
if mqtt_manager is None or not mqtt_source_id:
return DeviceHealth(
online=False, error="MQTT disabled", last_checked=datetime.now(timezone.utc)
online=False,
error="MQTT source not configured",
last_checked=datetime.now(timezone.utc),
)
runtime = mqtt_manager.get_runtime(mqtt_source_id)
connected = bool(runtime and runtime.is_connected)
return DeviceHealth(
online=svc.is_connected,
online=connected,
last_checked=datetime.now(timezone.utc),
error=None if svc.is_connected else "MQTT broker disconnected",
error=None if connected else "MQTT broker disconnected",
)
@@ -38,6 +38,8 @@ class MQTTDeviceProvider(LEDDeviceProvider):
return MQTTLEDClient(
config.device_url,
led_count=config.led_count,
mqtt_manager=deps.mqtt_manager,
mqtt_source_id=config.mqtt_source_id,
)
async def check_health(
@@ -46,7 +48,17 @@ class MQTTDeviceProvider(LEDDeviceProvider):
http_client,
prev_health=None,
) -> DeviceHealth:
return await MQTTLEDClient.check_health(url, http_client, prev_health)
# The provider-level check doesn't know which source the device
# references; the runtime check is best-effort. The healthier path
# is to wire mqtt_manager into the health monitor — for now we just
# return "unknown" so the dashboard doesn't show a stale "online".
from datetime import datetime, timezone
return DeviceHealth(
online=False,
error="MQTT health requires mqtt_source_id (per-device runtime check)",
last_checked=datetime.now(timezone.utc),
)
async def validate_device(self, url: str) -> dict:
"""Validate MQTT device URL (topic path)."""
@@ -0,0 +1,284 @@
"""Nanoleaf OpenAPI LED client.
Nanoleaf controllers (Light Panels, Canvas, Shapes, Lines, Elements) expose
an HTTP REST API on port 16021. Pairing follows the documented two-step
handshake: the user holds the controller's power button for 5 seconds to
open a 30-second pairing window, then we POST to ``/api/v1/new`` to claim
an auth token. The token is long-lived and gets stored on the device.
Once paired, color control is a simple ``PUT /api/v1/{token}/state`` with
HSBT (hue / saturation / brightness; kelvin only matters when sat=0).
LedGrab averages the incoming strip to one HSB triple. Per-panel streaming
mode (``extControl`` UDP, ~60 Hz, addresses each panel individually) is
documented but not implemented here the MVP keeps the device acting as
a single-pixel target like Yeelight / Hue.
URL scheme: ``nanoleaf://<host>``. Port is fixed at 16021 on the protocol
side. The auth token is stored separately on the device config, not in
the URL putting it in the URL would leak the token into log files.
Reference: https://forum.nanoleaf.me/docs/openapi
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse
import httpx
import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient, PairingNotReady
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.utils import get_logger
logger = get_logger(__name__)
NANOLEAF_PORT = 16021
DEFAULT_MIN_INTERVAL_S = 0.1 # 10 Hz; HTTP per frame, plenty for averaged ambilight
def parse_nanoleaf_url(url: str) -> str:
"""Pull the host out of ``nanoleaf://host`` or accept a bare host.
The TCP port is fixed at 16021 on the protocol side; we ignore any
port specifier rather than silently accept one the controller won't
answer on.
"""
if not url:
raise ValueError("Nanoleaf URL is empty")
raw = url.strip()
if "://" in raw:
parsed = urlparse(raw)
host = parsed.hostname or ""
else:
parsed = urlparse(f"nanoleaf://{raw}")
host = parsed.hostname or ""
if not host:
raise ValueError(f"Nanoleaf URL has no host: {url!r}")
return host
def rgb_to_hsb(r: int, g: int, b: int) -> Tuple[int, int, int]:
"""Convert 8-bit RGB to Nanoleaf HSB (hue 0-360, sat 0-100, bri 0-100).
All outputs are integers; the Nanoleaf API rejects fractional values.
"""
r_n = max(0, min(255, r)) / 255.0
g_n = max(0, min(255, g)) / 255.0
b_n = max(0, min(255, b)) / 255.0
c_max = max(r_n, g_n, b_n)
c_min = min(r_n, g_n, b_n)
delta = c_max - c_min
if delta == 0:
h = 0.0
elif c_max == r_n:
h = 60.0 * (((g_n - b_n) / delta) % 6)
elif c_max == g_n:
h = 60.0 * (((b_n - r_n) / delta) + 2)
else:
h = 60.0 * (((r_n - g_n) / delta) + 4)
hue = int(round(h)) % 360
sat = 0 if c_max == 0 else int(round((delta / c_max) * 100))
bri = int(round(c_max * 100))
return hue, sat, bri
async def pair_nanoleaf(host: str, timeout_s: float = 4.0) -> str:
"""POST to ``/api/v1/new``; user must have just held the power button 5s.
Returns the auth token on success. Raises ``PairingNotReady`` if the
controller responds 403 (not in pairing mode) the caller surfaces
that as a 409 to the UI with a retry prompt.
"""
base = f"http://{host}:{NANOLEAF_PORT}/api/v1/new"
try:
async with httpx.AsyncClient(timeout=timeout_s) as http_client:
response = await http_client.post(base)
except (httpx.HTTPError, httpx.TimeoutException) as exc:
raise RuntimeError(f"Pairing transport failure for {host}: {exc}") from exc
if response.status_code == 403:
raise PairingNotReady(
"Hold the power button on your Nanoleaf controller for 5 seconds "
"until the LEDs flash, then click Try again."
)
if response.status_code != 200:
raise RuntimeError(
f"Nanoleaf at {host} returned HTTP {response.status_code} during pairing: "
f"{response.text[:200]}"
)
try:
token = response.json().get("auth_token")
except ValueError as exc:
raise RuntimeError(f"Malformed pairing response from {host}: {exc}") from exc
if not token or not isinstance(token, str):
raise RuntimeError(f"Nanoleaf at {host} returned no auth_token in pairing response")
return token
class NanoleafClient(LEDClient):
"""LEDClient for a single Nanoleaf controller (Panels / Canvas / Shapes)."""
def __init__(
self,
url: str,
led_count: int = 1,
*,
auth_token: str = "",
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
request_timeout_s: float = 3.0,
):
self._host = parse_nanoleaf_url(url)
self._token = auth_token
self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s)
self._request_timeout_s = request_timeout_s
self._http: Optional[httpx.AsyncClient] = None
self._connected = False
self._next_tx_at: float = 0.0
@property
def host(self) -> str:
return self._host
@property
def port(self) -> int:
"""Fixed at the protocol-mandated 16021. Exposed for cross-driver
symmetry with the UDP/TCP clients that also surface ``.port``."""
return NANOLEAF_PORT
@property
def is_connected(self) -> bool:
return self._connected and self._http is not None
@property
def device_led_count(self) -> Optional[int]:
return self._led_count or None
def _state_url(self) -> str:
return f"http://{self._host}:{NANOLEAF_PORT}/api/v1/{self._token}/state"
async def connect(self) -> bool:
if self._connected and self._http is not None:
return True
if not self._token:
raise RuntimeError("NanoleafClient requires an auth_token; pair the device first")
self._http = httpx.AsyncClient(timeout=self._request_timeout_s)
self._connected = True
logger.info("NanoleafClient connected to %s:%d", self._host, NANOLEAF_PORT)
return True
async def close(self) -> None:
if self._http is not None:
try:
await self._http.aclose()
except (httpx.HTTPError, RuntimeError):
pass
self._http = None
self._connected = False
async def _put_state(self, body: dict) -> None:
if self._http is None:
raise RuntimeError("NanoleafClient not connected")
response = await self._http.put(self._state_url(), json=body)
# 204 No Content is the documented success; 200 also acceptable in practice.
if response.status_code not in (200, 204):
raise RuntimeError(
f"Nanoleaf rejected state update ({response.status_code}): "
f"{response.text[:200]}"
)
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
"""Average the strip and PUT a single HSB state update."""
if not self.is_connected:
raise RuntimeError("NanoleafClient not connected")
loop_now = asyncio.get_event_loop().time()
if loop_now < self._next_tx_at:
return True
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
hue, sat, bri = rgb_to_hsb(r, g, b)
# Nanoleaf rejects brightness=0; clamp to 1 so the user can still see
# "almost off" without falling off the API.
bri = max(1, bri)
await self._put_state(
{
"hue": {"value": hue},
"sat": {"value": sat},
"brightness": {"value": bri, "duration": 0},
}
)
self._next_tx_at = loop_now + self._min_interval_s
return True
async def set_color(self, r: int, g: int, b: int) -> None:
if not self.is_connected:
raise RuntimeError("NanoleafClient not connected")
hue, sat, bri = rgb_to_hsb(r, g, b)
bri = max(1, bri)
await self._put_state(
{
"hue": {"value": hue},
"sat": {"value": sat},
"brightness": {"value": bri, "duration": 0},
}
)
async def set_power(self, on: bool) -> None:
if not self.is_connected:
raise RuntimeError("NanoleafClient not connected")
await self._put_state({"on": {"value": on}})
async def set_brightness(self, brightness_0_100: int) -> None:
if not self.is_connected:
raise RuntimeError("NanoleafClient not connected")
clamped = max(0, min(100, brightness_0_100))
await self._put_state({"brightness": {"value": clamped, "duration": 0}})
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""GET ``/api/v1/<token>/info``. Without a token we can't authenticate,
so we fall back to GET ``/api/v1`` which returns 401 when the host is
a real Nanoleaf controller and connection-error otherwise."""
now = datetime.now(timezone.utc)
try:
host = parse_nanoleaf_url(url)
except ValueError as exc:
return DeviceHealth(online=False, last_checked=now, error=str(exc))
probe_url = f"http://{host}:{NANOLEAF_PORT}/api/v1"
loop = asyncio.get_running_loop()
start = loop.time()
try:
response = await http_client.get(probe_url, timeout=2.0)
except (httpx.HTTPError, httpx.TimeoutException) as exc:
return DeviceHealth(
online=False,
last_checked=now,
error=f"Nanoleaf unreachable at {host}: {exc}",
)
latency_ms = (loop.time() - start) * 1000.0
# Real Nanoleaf responds 401/403 on /api/v1 without an auth token;
# anything else may still be alive. Both signal "host is up".
return DeviceHealth(
online=True,
latency_ms=latency_ms,
last_checked=now,
device_version=str(response.status_code),
)
@@ -0,0 +1,167 @@
"""Nanoleaf device provider — Light Panels / Canvas / Shapes / Lines / Elements."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, List
from ledgrab.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
ProviderDeps,
)
from ledgrab.core.devices.nanoleaf_client import (
NanoleafClient,
pair_nanoleaf,
parse_nanoleaf_url,
)
from ledgrab.utils.net_classify import validate_lan_host
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.devices.device_config import NanoleafConfig
logger = get_logger(__name__)
class NanoleafDeviceProvider(LEDDeviceProvider):
"""Provider for Nanoleaf controllers.
Treats the controller as a single-pixel target (averaged strip one
HSB color via ``PUT /state``). Per-panel addressing via the
``extControl`` UDP streaming mode is a follow-up the MVP keeps it
simple and matches the shape of every other consumer-bulb driver.
Requires pairing: the user holds the controller's power button for
five seconds to open a 30-second window, then the frontend pair
modal POSTs to ``/api/v1/new`` via ``pair_device``.
"""
@property
def device_type(self) -> str:
return "nanoleaf"
@property
def capabilities(self) -> set:
return {
"manual_led_count",
"requires_pairing",
"power_control",
"brightness_control",
"static_color",
"health_check",
"single_pixel",
}
def create_client(self, config: "NanoleafConfig", *, deps: ProviderDeps) -> LEDClient:
return NanoleafClient(
config.device_url,
led_count=config.led_count,
auth_token=config.nanoleaf_token,
min_interval_s=max(0.0, config.nanoleaf_min_interval_ms / 1000.0),
)
async def pair_device(self, url: str) -> dict:
"""Claim an auth token after the user has held the power button."""
try:
host = parse_nanoleaf_url(url)
except ValueError as exc:
raise ValueError(f"Invalid Nanoleaf URL: {exc}") from exc
validate_lan_host(host)
token = await pair_nanoleaf(host)
return {"nanoleaf_token": token}
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await NanoleafClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Resolve the host. LED-count is user-supplied (matches the
existing single-pixel pattern) Nanoleaf reports panel count
through ``panelLayout``, but for the single-color streaming
shape it's not needed."""
try:
host = parse_nanoleaf_url(url)
except ValueError as exc:
raise ValueError(f"Invalid Nanoleaf URL: {exc}") from exc
validate_lan_host(host)
logger.info("Nanoleaf URL validated: host=%s", host)
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Scan via mDNS ``_nanoleafapi._tcp``.
Both the newer ``_nanoleafapi._tcp`` and older ``_nanoleaf._tcp``
service types appear in the field. We query both in parallel and
deduplicate by IP.
"""
try:
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
except ImportError:
logger.warning("zeroconf unavailable; skipping Nanoleaf mDNS discovery")
return []
service_types = ("_nanoleafapi._tcp.local.", "_nanoleafapi_v1._tcp.local.")
discovered: dict[str, AsyncServiceInfo] = {}
def _on_state_change(**kwargs):
service_type = kwargs.get("service_type", "")
name = kwargs.get("name", "")
state_change = kwargs.get("state_change")
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
discovered[name] = AsyncServiceInfo(service_type, name)
try:
aiozc = AsyncZeroconf()
except OSError as exc:
logger.warning("Nanoleaf discovery: zeroconf init failed (%s)", exc)
return []
browsers: list = []
try:
browsers = [
AsyncServiceBrowser(aiozc.zeroconf, st, handlers=[_on_state_change])
for st in service_types
]
await asyncio.sleep(timeout)
for info in discovered.values():
await info.async_request(aiozc.zeroconf, timeout=2000)
finally:
# Cancel browsers in finally so a CancelledError during sleep or
# async_request still tears them down — caught by review MEDIUM #8.
for browser in browsers:
try:
await browser.async_cancel()
except (OSError, RuntimeError):
pass
try:
await aiozc.async_close()
except (OSError, RuntimeError):
pass
results: List[DiscoveredDevice] = []
seen_ips: set[str] = set()
for name, info in discovered.items():
addrs = info.parsed_addresses() if info else []
if not addrs:
continue
ip = addrs[0]
if ip in seen_ips:
continue
seen_ips.add(ip)
service_name = name.rsplit(".", 4)[0] if "." in name else name
results.append(
DiscoveredDevice(
name=service_name or "Nanoleaf",
url=f"nanoleaf://{ip}",
device_type="nanoleaf",
ip=ip,
mac="",
led_count=None,
version=None,
)
)
logger.info("Nanoleaf mDNS scan found %d controller(s)", len(results))
return results
@@ -0,0 +1,229 @@
"""Open Pixel Control (OPC) LED client.
OPC is a tiny TCP-based protocol used by Fadecandy boards, OpenRGB-OPC
bridges, art-installation controllers, and a variety of hobbyist
LED-driver software. Each packet is a 4-byte header followed by a body:
[channel:1][command:1][length_hi:1][length_lo:1][body]
For pixel data we use ``command=0`` (set 8-bit pixel colors) with an
RGB body. ``channel=0`` broadcasts to every channel on the server;
channels 1-255 address a specific output. The connection is
persistent open once and stream frames forever.
URL scheme: ``opc://<host>[:port]`` or bare ``<host>[:port]``.
Default port 7890.
Reference: https://github.com/zestyping/openpixelcontrol
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse
import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.utils import get_logger
logger = get_logger(__name__)
OPC_PORT = 7890
OPC_CMD_SET_PIXELS = 0
def parse_opc_url(url: str) -> Tuple[str, int]:
"""Pull ``(host, port)`` from ``opc://host[:port]`` or bare ``host[:port]``."""
if not url:
raise ValueError("OPC URL is empty")
raw = url.strip()
if "://" in raw:
parsed = urlparse(raw)
host = parsed.hostname or ""
port = parsed.port or OPC_PORT
else:
parsed = urlparse(f"opc://{raw}")
host = parsed.hostname or ""
port = parsed.port or OPC_PORT
if not host:
raise ValueError(f"OPC URL has no host: {url!r}")
return host, port
def _build_set_pixels_header(channel: int, body_len: int) -> bytes:
"""Pack the 4-byte OPC header for a SET_PIXELS frame."""
return bytes(
[
channel & 0xFF,
OPC_CMD_SET_PIXELS,
(body_len >> 8) & 0xFF,
body_len & 0xFF,
]
)
class OPCClient(LEDClient):
"""LEDClient for an Open Pixel Control receiver."""
def __init__(
self,
url: str,
led_count: int = 0,
*,
channel: int = 0,
connect_timeout_s: float = 3.0,
):
host, port = parse_opc_url(url)
self._host = host
self._port = port
self._led_count = led_count
self._channel = channel & 0xFF
self._connect_timeout_s = connect_timeout_s
self._writer: Optional[asyncio.StreamWriter] = None
self._reader: Optional[asyncio.StreamReader] = None
self._connected = False
@property
def host(self) -> str:
return self._host
@property
def port(self) -> int:
return self._port
@property
def channel(self) -> int:
return self._channel
@property
def is_connected(self) -> bool:
return self._connected and self._writer is not None
@property
def device_led_count(self) -> Optional[int]:
return self._led_count or None
async def connect(self) -> bool:
if self._connected and self._writer is not None:
return True
try:
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self._host, self._port),
timeout=self._connect_timeout_s,
)
except (OSError, asyncio.TimeoutError) as exc:
raise RuntimeError(f"Failed to connect to OPC at {self._host}: {exc}") from exc
self._connected = True
logger.info(
"OPCClient connected to %s:%d (channel=%d)",
self._host,
self._port,
self._channel,
)
return True
async def close(self) -> None:
if self._writer is not None:
try:
self._writer.close()
await self._writer.wait_closed()
except (OSError, asyncio.CancelledError):
pass
self._writer = None
self._reader = None
self._connected = False
@staticmethod
def _apply_brightness(pixels: np.ndarray, brightness: int) -> np.ndarray:
if brightness >= 255:
return pixels
if brightness <= 0:
return np.zeros_like(pixels)
return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8)
def _as_numpy(self, pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> np.ndarray:
if isinstance(pixels, np.ndarray):
arr = pixels
else:
arr = np.asarray(pixels, dtype=np.uint8)
if arr.dtype != np.uint8:
arr = arr.astype(np.uint8)
if arr.ndim == 1 and arr.shape[0] % 3 == 0:
arr = arr.reshape(-1, 3)
if not arr.flags["C_CONTIGUOUS"]:
arr = np.ascontiguousarray(arr)
return arr
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self.is_connected:
raise RuntimeError("OPCClient not connected")
arr = self._apply_brightness(self._as_numpy(pixels), brightness)
body = arr.tobytes()
header = _build_set_pixels_header(self._channel, len(body))
assert self._writer is not None
self._writer.write(header)
self._writer.write(body)
await self._writer.drain()
return True
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> None:
"""Synchronous hot-path write. Drain runs implicitly when the OS buffer
flushes for the ambilight loop, dropping the await is the point."""
if not self.is_connected or self._writer is None:
raise RuntimeError("OPCClient not connected")
arr = self._apply_brightness(self._as_numpy(pixels), brightness)
body = arr.tobytes()
header = _build_set_pixels_header(self._channel, len(body))
self._writer.write(header)
self._writer.write(body)
@property
def supports_fast_send(self) -> bool:
return True
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Open a TCP connection and close it. OPC has no protocol-level
ping; reachable TCP is the strongest signal we get."""
now = datetime.now(timezone.utc)
try:
host, port = parse_opc_url(url)
except ValueError as exc:
return DeviceHealth(online=False, last_checked=now, error=str(exc))
loop = asyncio.get_running_loop()
start = loop.time()
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=2.0,
)
except (OSError, asyncio.TimeoutError) as exc:
return DeviceHealth(
online=False,
last_checked=now,
error=f"OPC unreachable at {host}:{port}: {exc}",
)
latency_ms = (loop.time() - start) * 1000.0
writer.close()
try:
await writer.wait_closed()
except OSError:
pass
del reader
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
@@ -0,0 +1,63 @@
"""Open Pixel Control device provider — Fadecandy and OPC-compatible receivers."""
from __future__ import annotations
from typing import TYPE_CHECKING, List
from ledgrab.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
ProviderDeps,
)
from ledgrab.core.devices.opc_client import OPCClient, parse_opc_url
from ledgrab.utils.net_classify import validate_lan_host
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.devices.device_config import OPCConfig
logger = get_logger(__name__)
class OPCDeviceProvider(LEDDeviceProvider):
"""Provider for Open Pixel Control receivers (Fadecandy, OPC bridges, etc.).
OPC has no native discovery protocol users supply an IP. The channel
field (default 0 = broadcast to all OPC channels) routes pixel data to
a specific output on multi-channel servers.
"""
@property
def device_type(self) -> str:
return "opc"
@property
def capabilities(self) -> set:
# OPC has no reply channel; no power / brightness query.
# Software brightness still applies client-side before the frame is sent.
return {"manual_led_count", "health_check"}
def create_client(self, config: "OPCConfig", *, deps: ProviderDeps) -> LEDClient:
return OPCClient(
config.device_url,
led_count=config.led_count,
channel=config.opc_channel,
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await OPCClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
try:
host, port = parse_opc_url(url)
except ValueError as exc:
raise ValueError(f"Invalid OPC URL: {exc}") from exc
validate_lan_host(host)
logger.info("OPC device URL validated: host=%s port=%d", host, port)
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""OPC has no discovery protocol — returns empty list."""
return []
@@ -0,0 +1,42 @@
"""Pixel-reduction helpers shared across single-pixel LED clients.
Single-pixel devices (Yeelight, WiZ, LIFX, Govee, Nanoleaf, BLE bulbs)
all need to collapse an N-pixel strip down to one RGB triple before
sending it on the wire. This module is the single home for that reduction
so the next single-pixel driver can drop the local copy.
Hue is the exception its Entertainment API addresses up to seven
zones individually, so it doesn't reduce.
"""
from __future__ import annotations
from typing import List, Tuple, Union
import numpy as np
def average_color(
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
) -> Tuple[int, int, int]:
"""Reduce an N-pixel strip to one average RGB triple.
Accepts either a list of ``(r, g, b)`` tuples or an ``(N, 3)`` uint8
numpy array. Empty inputs return ``(0, 0, 0)`` rather than raising so
callers can pass through a degenerate frame without branching.
"""
if isinstance(pixels, np.ndarray):
if pixels.size == 0:
return (0, 0, 0)
arr = pixels.reshape(-1, 3) if pixels.ndim > 1 else pixels[:3].reshape(1, 3)
mean = arr.mean(axis=0)
return int(mean[0]), int(mean[1]), int(mean[2])
if not pixels:
return (0, 0, 0)
total_r = total_g = total_b = 0
for r, g, b in pixels:
total_r += r
total_g += g
total_b += b
n = len(pixels)
return total_r // n, total_g // n, total_b // n
@@ -0,0 +1,303 @@
"""WiZ Connected (Philips' budget tier) LAN LED client.
WiZ bulbs accept JSON commands as UDP datagrams on port 38899. There's no
persistent connection every frame is fire-and-forget so the client is
simpler than Yeelight and tolerates higher update rates.
URL scheme: ``wiz://<host>[:port]`` or bare ``<host>``. Default port 38899.
Discovery: UDP broadcast of a ``registration`` envelope on
255.255.255.255:38899 bulbs reply unicast with their MAC and state.
"""
from __future__ import annotations
import asyncio
import json
import socket
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse
import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.utils import get_logger
logger = get_logger(__name__)
WIZ_PORT = 38899
DEFAULT_MIN_INTERVAL_S = 0.05 # ~20 Hz cap; bulbs tolerate it, UDP costs nothing
def parse_wiz_url(url: str) -> Tuple[str, int]:
"""Pull ``(host, port)`` from ``wiz://host[:port]`` or a bare ``host[:port]``."""
if not url:
raise ValueError("WiZ URL is empty")
raw = url.strip()
if "://" in raw:
parsed = urlparse(raw)
host = parsed.hostname or ""
port = parsed.port or WIZ_PORT
else:
parsed = urlparse(f"wiz://{raw}")
host = parsed.hostname or ""
port = parsed.port or WIZ_PORT
if not host:
raise ValueError(f"WiZ URL has no host: {url!r}")
return host, port
class _WiZProtocol(asyncio.DatagramProtocol):
"""Minimal protocol: sends only, drops any inbound packets silently."""
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
# WiZ bulbs reply to setPilot with a small ack. We don't need it for
# ambilight streaming — just drop the bytes on the floor.
pass
def error_received(self, exc):
# UDP errors (ICMP unreachable, route changes) surface here. Log
# once and let the next frame retry; transient drops are normal.
logger.debug("WiZ UDP error: %s", exc)
class WiZClient(LEDClient):
"""LEDClient for a single WiZ Connected bulb on the LAN."""
def __init__(
self,
url: str,
led_count: int = 1,
*,
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
):
host, port = parse_wiz_url(url)
self._host = host
self._port = port
self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s)
self._transport: Optional[asyncio.DatagramTransport] = None
self._protocol: Optional[_WiZProtocol] = None
self._connected = False
self._next_tx_at: float = 0.0
@property
def host(self) -> str:
return self._host
@property
def port(self) -> int:
return self._port
@property
def is_connected(self) -> bool:
return self._connected and self._transport is not None
@property
def device_led_count(self) -> Optional[int]:
return self._led_count or None
async def connect(self) -> bool:
if self._connected and self._transport is not None:
return True
loop = asyncio.get_running_loop()
try:
transport, protocol = await loop.create_datagram_endpoint(
_WiZProtocol, remote_addr=(self._host, self._port)
)
except OSError as exc:
raise RuntimeError(f"Failed to open UDP to WiZ at {self._host}: {exc}") from exc
self._transport = transport
self._protocol = protocol # type: ignore[assignment]
self._connected = True
logger.info("WiZClient connected to %s:%d", self._host, self._port)
return True
async def close(self) -> None:
if self._transport is not None:
try:
self._transport.close()
except OSError:
pass
self._transport = None
self._protocol = None
self._connected = False
def _send_json(self, payload: dict) -> None:
"""Fire one JSON UDP packet. Caller must hold an open transport."""
assert self._transport is not None
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
self._transport.sendto(raw)
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
"""Average the pixel strip to one color and push ``setPilot``."""
if not self.is_connected:
raise RuntimeError("WiZClient not connected")
now = time.monotonic()
if now < self._next_tx_at:
return True
r, g, b = _average_color(pixels)
# WiZ has a separate "dimming" param (1-100). For ambilight we keep
# things linear and fold brightness into the RGB scalars — that's
# what the bulb shows anyway with state=on.
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
self._next_tx_at = now + self._min_interval_s
return True
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> None:
"""Synchronous variant for the hot path. Same shape as send_pixels."""
if not self.is_connected or self._transport is None:
raise RuntimeError("WiZClient not connected")
now = time.monotonic()
if now < self._next_tx_at:
return
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
self._next_tx_at = now + self._min_interval_s
@property
def supports_fast_send(self) -> bool:
# WiZ is UDP fire-and-forget — perfect candidate for the sync hot path.
return True
async def set_color(self, r: int, g: int, b: int) -> None:
if not self.is_connected:
raise RuntimeError("WiZClient not connected")
self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
async def set_brightness(self, brightness_0_100: int) -> None:
if not self.is_connected:
raise RuntimeError("WiZClient not connected")
clamped = max(10, min(100, brightness_0_100)) # WiZ rejects <10
self._send_json({"method": "setPilot", "params": {"dimming": clamped}})
async def set_power(self, on: bool) -> None:
if not self.is_connected:
raise RuntimeError("WiZClient not connected")
self._send_json({"method": "setPilot", "params": {"state": on}})
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Send a getPilot and wait briefly for any reply on a one-shot socket."""
now = datetime.now(timezone.utc)
try:
host, port = parse_wiz_url(url)
except ValueError as exc:
return DeviceHealth(online=False, last_checked=now, error=str(exc))
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setblocking(False)
try:
sock.bind(("", 0))
start = loop.time()
await loop.sock_sendto(sock, b'{"method":"getPilot","params":{}}', (host, port))
try:
await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=1.5)
except asyncio.TimeoutError:
return DeviceHealth(
online=False,
last_checked=now,
error=f"No WiZ reply from {host}:{port} within 1.5s",
)
latency_ms = (loop.time() - start) * 1000.0
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
except OSError as exc:
return DeviceHealth(
online=False,
last_checked=now,
error=f"WiZ probe failed for {host}: {exc}",
)
finally:
sock.close()
# ============================================================================
# Broadcast discovery
# ============================================================================
_DISCOVERY_REQUEST = (
b'{"method":"registration","params":{"phoneMac":"AAAAAAAAAAAA","register":false,'
b'"phoneIp":"0.0.0.0","id":"1"}}'
)
def _extract_mac(payload: dict) -> str:
"""Pull a bulb MAC out of the standard ``result`` envelope, if present."""
result = payload.get("result")
if isinstance(result, dict):
return str(result.get("mac", "")).lower()
return ""
async def discover_wiz_bulbs(timeout: float = 2.0) -> List[dict]:
"""Broadcast a registration probe and collect bulb replies.
Returns a list of ``{"ip": ..., "mac": ..., "raw": <parsed_json>}`` dicts.
Multicast / broadcast failures (no network, firewall) raise OSError;
callers handle that by returning an empty discovery list.
"""
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.setblocking(False)
try:
sock.bind(("", 0))
await loop.sock_sendto(sock, _DISCOVERY_REQUEST, ("255.255.255.255", WIZ_PORT))
results: list[dict] = []
seen_ips: set[str] = set()
deadline = loop.time() + timeout
while True:
remaining = deadline - loop.time()
if remaining <= 0:
break
try:
raw, addr = await asyncio.wait_for(
loop.sock_recvfrom(sock, 2048),
timeout=remaining,
)
except asyncio.TimeoutError:
break
ip = addr[0]
if ip in seen_ips:
continue
try:
payload = json.loads(raw.decode("utf-8", errors="replace"))
except (json.JSONDecodeError, UnicodeDecodeError):
continue
if not isinstance(payload, dict):
continue
seen_ips.add(ip)
results.append({"ip": ip, "mac": _extract_mac(payload), "raw": payload})
return results
finally:
sock.close()
@@ -0,0 +1,94 @@
"""WiZ Connected device provider — LAN-discoverable Philips budget-tier bulbs."""
from __future__ import annotations
from typing import TYPE_CHECKING, List
from ledgrab.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
ProviderDeps,
)
from ledgrab.core.devices.wiz_client import (
WiZClient,
discover_wiz_bulbs,
parse_wiz_url,
)
from ledgrab.utils.net_classify import validate_lan_host
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.devices.device_config import WiZConfig
logger = get_logger(__name__)
class WiZDeviceProvider(LEDDeviceProvider):
"""Provider for WiZ Connected (Philips budget-tier) bulbs.
Single-pixel device, identical adaptation shape as Yeelight/Hue.
"""
@property
def device_type(self) -> str:
return "wiz"
@property
def capabilities(self) -> set:
return {
"manual_led_count",
"power_control",
"brightness_control",
"static_color",
"health_check",
"single_pixel",
}
def create_client(self, config: "WiZConfig", *, deps: ProviderDeps) -> LEDClient:
return WiZClient(
config.device_url,
led_count=config.led_count,
min_interval_s=max(0.0, config.wiz_min_interval_ms / 1000.0),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await WiZClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
try:
host, port = parse_wiz_url(url)
except ValueError as exc:
raise ValueError(f"Invalid WiZ URL: {exc}") from exc
validate_lan_host(host)
logger.info("WiZ device URL validated: host=%s port=%d", host, port)
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
try:
bulbs = await discover_wiz_bulbs(timeout=min(timeout, 5.0))
except (OSError, RuntimeError) as exc:
logger.warning("WiZ discovery failed: %s", exc)
return []
results: List[DiscoveredDevice] = []
for bulb in bulbs:
ip = bulb.get("ip", "")
if not ip:
continue
url = f"wiz://{ip}"
mac = bulb.get("mac", "")
results.append(
DiscoveredDevice(
name=f"WiZ {mac[-6:]}" if mac else "WiZ bulb",
url=url,
device_type="wiz",
ip=ip,
mac=mac,
led_count=None,
version=None,
)
)
logger.info("WiZ broadcast scan found %d bulb(s)", len(results))
return results
@@ -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()
@@ -0,0 +1,293 @@
"""Yeelight (Xiaomi) LAN LED client.
Yeelight bulbs and lightstrips accept JSON-RPC commands over a plain TCP
socket on port 55443. This client speaks the simplest useful subset
``set_rgb``, ``set_bright``, ``set_power``, ``get_prop`` and averages the
incoming pixel strip down to one RGB color (Yeelight bulbs are single-pixel
devices, like Hue or generic BLE bulbs).
Rate limit: each Yeelight bulb caps inbound commands at roughly one per
second by default. We enforce a configurable client-side gate to stay under
that limit; faster updates would need Yeelight "music mode" (bulb dials
back to our TCP server) which is a follow-up.
URL scheme: ``yeelight://<host>`` or bare ``<host>``. Port is fixed at 55443
on the protocol side; we don't parse it from the URL.
"""
from __future__ import annotations
import asyncio
import json
import socket
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse
import numpy as np
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
from ledgrab.utils import get_logger
logger = get_logger(__name__)
YEELIGHT_PORT = 55443
DEFAULT_MIN_INTERVAL_S = 0.5 # half a second between TX → ~2 Hz, well under the cap
def parse_yeelight_url(url: str) -> str:
"""Pull the host out of ``yeelight://host`` or accept a bare ``host``.
The TCP port is fixed on the protocol side (55443), so we ignore any port
specifier rather than silently accept one the bulb won't answer on.
"""
if not url:
raise ValueError("Yeelight URL is empty")
raw = url.strip()
if "://" in raw:
parsed = urlparse(raw)
host = parsed.hostname or ""
else:
parsed = urlparse(f"yeelight://{raw}")
host = parsed.hostname or ""
if not host:
raise ValueError(f"Yeelight URL has no host: {url!r}")
return host
def _pack_rgb(r: int, g: int, b: int) -> int:
"""Pack an (R, G, B) triple into the 24-bit integer Yeelight expects."""
return ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF)
class YeelightClient(LEDClient):
"""LEDClient for a single Yeelight bulb / lightstrip on the LAN."""
def __init__(
self,
url: str,
led_count: int = 1,
*,
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
connect_timeout_s: float = 3.0,
):
self._host = parse_yeelight_url(url)
self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s)
self._connect_timeout_s = connect_timeout_s
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None
self._connected = False
self._next_tx_at: float = 0.0
self._req_id: int = 0
self._send_lock = asyncio.Lock()
@property
def host(self) -> str:
return self._host
@property
def is_connected(self) -> bool:
return self._connected and self._writer is not None
@property
def device_led_count(self) -> Optional[int]:
return self._led_count or None
async def connect(self) -> bool:
if self._connected:
return True
try:
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self._host, YEELIGHT_PORT),
timeout=self._connect_timeout_s,
)
except (OSError, asyncio.TimeoutError) as exc:
raise RuntimeError(f"Failed to connect to Yeelight at {self._host}: {exc}") from exc
self._connected = True
logger.info("YeelightClient connected to %s:%d", self._host, YEELIGHT_PORT)
return True
async def close(self) -> None:
if self._writer is not None:
try:
self._writer.close()
await self._writer.wait_closed()
except (OSError, asyncio.CancelledError):
pass
self._writer = None
self._reader = None
self._connected = False
def _next_id(self) -> int:
self._req_id = (self._req_id + 1) % 1_000_000
return self._req_id
async def _send(self, method: str, params: list) -> None:
"""Fire a JSON-RPC command; replies are read-then-dropped opportunistically.
Yeelight's bulb sends a JSON reply per command, but for streaming
ambient lighting we don't need to wait for it — the data is
write-only.
"""
if self._writer is None:
raise RuntimeError("YeelightClient not connected")
payload = json.dumps({"id": self._next_id(), "method": method, "params": params}) + "\r\n"
async with self._send_lock:
self._writer.write(payload.encode("utf-8"))
await self._writer.drain()
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
"""Average the pixel strip to one color and ``set_rgb``.
Brightness is folded in by scaling the averaged RGB rather than
sending a separate ``set_bright`` (avoids burning a command and
keeps animation in sync). When the configured min interval hasn't
elapsed the call returns ``True`` without TX the next frame will
carry whichever color was current at the time it eventually fires.
"""
if not self.is_connected:
raise RuntimeError("YeelightClient not connected")
now = time.monotonic()
if now < self._next_tx_at:
return True
r, g, b = _average_color(pixels)
if brightness < 255:
scale = max(0, min(255, brightness)) / 255.0
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
packed = _pack_rgb(r, g, b)
# ``set_rgb`` params: [color_int, effect, duration_ms].
# "sudden" + 0ms keeps latency minimal for ambilight.
await self._send("set_rgb", [packed, "sudden", 0])
self._next_tx_at = now + self._min_interval_s
return True
async def set_color(self, r: int, g: int, b: int) -> None:
await self._send("set_rgb", [_pack_rgb(r, g, b), "sudden", 0])
async def set_brightness(self, brightness_0_100: int) -> None:
clamped = max(1, min(100, brightness_0_100))
await self._send("set_bright", [clamped, "sudden", 0])
async def set_power(self, on: bool) -> None:
await self._send("set_power", ["on" if on else "off", "sudden", 0])
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Health check: open the TCP socket to the bulb and close it."""
now = datetime.now(timezone.utc)
try:
host = parse_yeelight_url(url)
except ValueError as exc:
return DeviceHealth(online=False, last_checked=now, error=str(exc))
loop = asyncio.get_running_loop()
start = loop.time()
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, YEELIGHT_PORT),
timeout=2.0,
)
except (OSError, asyncio.TimeoutError) as exc:
return DeviceHealth(
online=False,
last_checked=now,
error=f"Yeelight unreachable at {host}:{YEELIGHT_PORT}: {exc}",
)
latency_ms = (loop.time() - start) * 1000.0
writer.close()
try:
await writer.wait_closed()
except OSError:
pass
del reader
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
# ============================================================================
# SSDP-style discovery
# ============================================================================
_DISCOVER_GROUP = ("239.255.255.250", 1982)
_DISCOVER_REQUEST = (
"M-SEARCH * HTTP/1.1\r\n"
"HOST: 239.255.255.250:1982\r\n"
'MAN: "ssdp:discover"\r\n'
"ST: wifi_bulb\r\n"
"\r\n"
).encode("ascii")
def _parse_ssdp_response(raw: bytes) -> Optional[dict]:
"""Parse a Yeelight discovery response into a ``{header: value}`` dict.
Returns ``None`` when the payload doesn't look like a Yeelight reply
(e.g. a stray HTTP response from another SSDP service on the LAN).
"""
try:
text = raw.decode("utf-8", errors="replace")
except UnicodeDecodeError:
return None
if "yeelight://" not in text.lower():
return None
headers: dict = {}
for line in text.splitlines():
if ":" in line:
key, _, value = line.partition(":")
headers[key.strip().lower()] = value.strip()
return headers
async def discover_yeelight_bulbs(timeout: float = 2.0) -> List[dict]:
"""Scan the LAN for Yeelight bulbs via the bulb-specific SSDP variant.
Returns a list of header-dicts (one per bulb that replied) so the caller
can decide which fields to surface. Each dict has ``location``,
``id``, ``model``, ``support``, ``rgb``, ``bright`` etc.
"""
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
sock.setblocking(False)
try:
sock.bind(("", 0))
await loop.sock_sendto(sock, _DISCOVER_REQUEST, _DISCOVER_GROUP)
results: list[dict] = []
seen_ids: set[str] = set()
deadline = loop.time() + timeout
while True:
remaining = deadline - loop.time()
if remaining <= 0:
break
try:
raw, _addr = await asyncio.wait_for(
loop.sock_recvfrom(sock, 2048),
timeout=remaining,
)
except asyncio.TimeoutError:
break
headers = _parse_ssdp_response(raw)
if not headers:
continue
bulb_id = headers.get("id", "")
if bulb_id and bulb_id in seen_ids:
continue
if bulb_id:
seen_ids.add(bulb_id)
results.append(headers)
return results
finally:
sock.close()
@@ -0,0 +1,109 @@
"""Yeelight device provider — LAN-discoverable Xiaomi smart bulbs."""
from __future__ import annotations
from typing import TYPE_CHECKING, List
from urllib.parse import urlparse
from ledgrab.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
ProviderDeps,
)
from ledgrab.core.devices.yeelight_client import (
YeelightClient,
discover_yeelight_bulbs,
parse_yeelight_url,
)
from ledgrab.utils.net_classify import validate_lan_host
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.devices.device_config import YeelightConfig
logger = get_logger(__name__)
class YeelightDeviceProvider(LEDDeviceProvider):
"""Provider for Yeelight (Xiaomi) LAN bulbs and lightstrips.
Single-pixel device: the LED client averages the incoming strip down to
one RGB color before sending. LED count is user-supplied; it controls
the pixel-source mapping, not anything on the wire.
"""
@property
def device_type(self) -> str:
return "yeelight"
@property
def capabilities(self) -> set:
return {
"manual_led_count",
"power_control",
"brightness_control",
"static_color",
"health_check",
"single_pixel",
}
def create_client(self, config: "YeelightConfig", *, deps: ProviderDeps) -> LEDClient:
return YeelightClient(
config.device_url,
led_count=config.led_count,
min_interval_s=max(0.0, config.yeelight_min_interval_ms / 1000.0),
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await YeelightClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate the URL is parseable. Yeelight bulbs are single-pixel so
we don't return a led_count — the user fills it in."""
try:
host = parse_yeelight_url(url)
except ValueError as exc:
raise ValueError(f"Invalid Yeelight URL: {exc}") from exc
validate_lan_host(host)
logger.info("Yeelight device URL validated: host=%s", host)
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Scan the LAN via Yeelight's SSDP variant on 239.255.255.250:1982."""
try:
bulbs = await discover_yeelight_bulbs(timeout=min(timeout, 5.0))
except (OSError, RuntimeError) as exc:
# Multicast can fail on Windows when no network is up, on
# firewalled hosts, or on Android sandboxes. Discovery is
# best-effort — log and return empty.
logger.warning("Yeelight discovery failed: %s", exc)
return []
results: List[DiscoveredDevice] = []
for headers in bulbs:
location = headers.get("location", "")
if not location:
continue
parsed = urlparse(location)
host = parsed.hostname or ""
if not host:
continue
url = f"yeelight://{host}"
model = headers.get("model") or "yeelight"
fw = headers.get("fw_ver") or None
bulb_id = headers.get("id", "") or host
results.append(
DiscoveredDevice(
name=f"Yeelight {model}".strip(),
url=url,
device_type="yeelight",
ip=host,
mac=bulb_id, # the bulb's hex id is the closest stable identifier
led_count=None,
version=fw,
)
)
logger.info("Yeelight SSDP scan found %d bulb(s)", len(results))
return results
@@ -0,0 +1,123 @@
"""One-shot migration: legacy global ``MQTTConfig`` (YAML/env) → first ``MQTTSource``.
Pre-multi-broker, LedGrab had a single MQTT broker configured under
``mqtt:`` in ``config.yaml`` (and ``LEDGRAB_MQTT__*`` env vars). After the
multi-broker refactor those settings are no longer read at runtime every
consumer references an ``MQTTSource`` id.
To avoid silently dropping the legacy broker config when upgrading, this
migrator runs once at startup. If the source store is empty AND the
legacy YAML/env values look configured (broker_host set or ``enabled=true``),
it creates a single :class:`MQTTSource` named "Default Broker" from those
values. After the first run nothing happens the store is the source of
truth.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
import yaml
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
def _legacy_mqtt_from_env() -> Optional[dict]:
"""Read legacy ``LEDGRAB_MQTT__*`` env vars (pydantic-settings convention)."""
enabled = os.environ.get("LEDGRAB_MQTT__ENABLED", "").strip().lower()
host = os.environ.get("LEDGRAB_MQTT__BROKER_HOST", "").strip()
if not host and enabled not in ("true", "1", "yes"):
return None
return {
"enabled": enabled in ("true", "1", "yes"),
"broker_host": host or "localhost",
"broker_port": int(os.environ.get("LEDGRAB_MQTT__BROKER_PORT", "1883") or "1883"),
"username": os.environ.get("LEDGRAB_MQTT__USERNAME", "") or "",
"password": os.environ.get("LEDGRAB_MQTT__PASSWORD", "") or "",
"client_id": os.environ.get("LEDGRAB_MQTT__CLIENT_ID", "ledgrab") or "ledgrab",
"base_topic": os.environ.get("LEDGRAB_MQTT__BASE_TOPIC", "ledgrab") or "ledgrab",
}
def _candidate_config_paths() -> list[Path]:
"""Yield the YAML files that might hold the legacy ``mqtt:`` block."""
paths: list[Path] = []
override = os.environ.get("LEDGRAB_CONFIG_PATH")
if override:
paths.append(Path(override))
if os.environ.get("LEDGRAB_DEMO", "").lower() in ("true", "1", "yes"):
paths.append(Path("config/demo_config.yaml"))
paths.append(Path("config/default_config.yaml"))
return paths
def _legacy_mqtt_from_yaml() -> Optional[dict]:
"""Read legacy ``mqtt:`` block from the platform config.yaml."""
for path in _candidate_config_paths():
if not path.is_file():
continue
try:
with open(path, "r", encoding="utf-8") as f:
doc = yaml.safe_load(f) or {}
except Exception as e:
logger.debug("Could not read %s for MQTT migration: %s", path, e)
continue
block = doc.get("mqtt") if isinstance(doc, dict) else None
if isinstance(block, dict):
host = (block.get("broker_host") or "").strip()
enabled = bool(block.get("enabled"))
if host or enabled:
return {
"enabled": enabled,
"broker_host": host or "localhost",
"broker_port": int(block.get("broker_port") or 1883),
"username": str(block.get("username") or ""),
"password": str(block.get("password") or ""),
"client_id": str(block.get("client_id") or "ledgrab"),
"base_topic": str(block.get("base_topic") or "ledgrab"),
}
return None
def migrate_legacy_mqtt_config(store: MQTTSourceStore) -> None:
"""Seed a "Default Broker" :class:`MQTTSource` if the store is empty
and a legacy YAML/env MQTT config is detected.
Idempotent: if the store already has any sources, this is a no-op.
Logs a deprecation warning when migration runs.
"""
if store.count() > 0:
return
legacy = _legacy_mqtt_from_env() or _legacy_mqtt_from_yaml()
if legacy is None:
return
try:
source = store.create_source(
name="Default Broker",
broker_host=legacy["broker_host"],
broker_port=legacy["broker_port"],
username=legacy["username"],
password=legacy["password"],
client_id=legacy["client_id"],
base_topic=legacy["base_topic"],
description="Migrated from legacy mqtt: config block",
)
except Exception as e:
logger.error("Failed to migrate legacy MQTT config: %s", e)
return
logger.warning(
"Migrated legacy MQTT config to source %s (%s:%d). "
"The 'mqtt:' YAML block and LEDGRAB_MQTT__* env vars are no longer "
"read; edit the broker in the UI under MQTT Sources from now on.",
source.id,
source.broker_host,
source.broker_port,
)
+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:
@@ -1,182 +0,0 @@
"""Singleton async MQTT service — shared broker connection for all features."""
import asyncio
import json
from typing import Callable, Dict, Optional, Set
import aiomqtt
from ledgrab.config import MQTTConfig
from ledgrab.utils import get_logger
logger = get_logger(__name__)
class MQTTService:
"""Manages a persistent MQTT broker connection.
Features:
- Publish messages (retained or transient)
- Subscribe to topics with callback dispatch
- Topic value cache for synchronous reads (automation condition evaluation)
- Auto-reconnect loop
- Birth / will messages for online status
"""
def __init__(self, config: MQTTConfig):
self._config = config
self._client: Optional[aiomqtt.Client] = None
self._task: Optional[asyncio.Task] = None
self._connected = False
# Subscription management
self._subscriptions: Dict[str, Set[Callable]] = {} # topic -> set of callbacks
self._topic_cache: Dict[str, str] = {} # topic -> last payload string
# Pending publishes queued while disconnected
self._publish_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
@property
def is_connected(self) -> bool:
return self._connected
@property
def is_enabled(self) -> bool:
return self._config.enabled
async def start(self) -> None:
if not self._config.enabled:
logger.info("MQTT service disabled in configuration")
return
if self._task is not None:
return
self._task = asyncio.create_task(self._connection_loop())
logger.info(
f"MQTT service starting — broker {self._config.broker_host}:{self._config.broker_port}"
)
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
logger.debug("MQTT background task cancelled")
pass
self._task = None
self._connected = False
logger.info("MQTT service stopped")
async def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0) -> None:
"""Publish a message. If disconnected, queue for later delivery."""
if not self._config.enabled:
return
if self._connected and self._client:
try:
await self._client.publish(topic, payload, retain=retain, qos=qos)
return
except Exception as e:
logger.warning(f"MQTT publish failed ({topic}): {e}")
# Queue for retry
try:
self._publish_queue.put_nowait((topic, payload, retain, qos))
except asyncio.QueueFull:
logger.warning("MQTT publish queue full, dropping message for topic %s", topic)
pass
async def subscribe(self, topic: str, callback: Callable) -> None:
"""Subscribe to a topic. Callback receives (topic: str, payload: str)."""
if topic not in self._subscriptions:
self._subscriptions[topic] = set()
self._subscriptions[topic].add(callback)
# Subscribe on the live client if connected
if self._connected and self._client:
try:
await self._client.subscribe(topic)
except Exception as e:
logger.warning(f"MQTT subscribe failed ({topic}): {e}")
def get_last_value(self, topic: str) -> Optional[str]:
"""Get cached last value for a topic (synchronous — for automation evaluation)."""
return self._topic_cache.get(topic)
async def _connection_loop(self) -> None:
"""Persistent connection loop with auto-reconnect."""
base_topic = self._config.base_topic
will_topic = f"{base_topic}/status"
will_payload = "offline"
while True:
try:
async with aiomqtt.Client(
hostname=self._config.broker_host,
port=self._config.broker_port,
username=self._config.username or None,
password=self._config.password or None,
identifier=self._config.client_id,
will=aiomqtt.Will(
topic=will_topic,
payload=will_payload,
retain=True,
),
) as client:
self._client = client
self._connected = True
logger.info("MQTT connected to broker")
# Publish birth message
await client.publish(will_topic, "online", retain=True)
# Re-subscribe to all registered topics
for topic in self._subscriptions:
await client.subscribe(topic)
# Drain pending publishes
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:
break
# Message receive loop
async for msg in client.messages:
topic_str = str(msg.topic)
payload_str = (
msg.payload.decode("utf-8", errors="replace") if msg.payload else ""
)
self._topic_cache[topic_str] = payload_str
# Dispatch to callbacks
for sub_topic, callbacks in self._subscriptions.items():
if aiomqtt.Topic(sub_topic).matches(msg.topic):
for cb in callbacks:
try:
if asyncio.iscoroutinefunction(cb):
asyncio.create_task(cb(topic_str, payload_str))
else:
cb(topic_str, payload_str)
except Exception as e:
logger.error(f"MQTT callback error ({topic_str}): {e}")
except asyncio.CancelledError:
break
except Exception as e:
self._connected = False
self._client = None
logger.warning(f"MQTT connection lost: {e}. Reconnecting in 5s...")
await asyncio.sleep(5)
# ===== State exposure helpers =====
async def publish_target_state(self, target_id: str, state: dict) -> None:
"""Publish target state to MQTT (called from event handler)."""
topic = f"{self._config.base_topic}/target/{target_id}/state"
await self.publish(topic, json.dumps(state), retain=True)
async def publish_automation_state(self, automation_id: str, action: str) -> None:
"""Publish automation state change to MQTT."""
topic = f"{self._config.base_topic}/automation/{automation_id}/state"
await self.publish(topic, json.dumps({"action": action}), retain=True)
@@ -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",
@@ -7,6 +7,13 @@ from typing import Optional
import numpy as np
try:
import cv2
_HAS_CV2 = True
except ImportError:
_HAS_CV2 = False
from ledgrab.core.capture.calibration import (
AdvancedPixelMapper,
CalibrationConfig,
@@ -203,6 +210,11 @@ class PictureColorStripStream(ColorStripStream):
def _processing_loop(self) -> None:
"""Background thread: poll source, process, cache colors."""
cached_frame = None
# Track the source LiveStream's frame_id for event-driven waits.
# When the source publishes a new frame, ``wait_for_new_frame`` wakes
# us immediately instead of waiting for our own polling tick — cuts
# glass-to-LED latency by up to a full frame_time at matched FPS.
last_source_id = self._live_stream.current_frame_id
# Scratch buffer pool (pre-allocated, resized when LED count changes)
_pool_n = 0
@@ -213,8 +225,14 @@ class PictureColorStripStream(ColorStripStream):
def _blend_u16(a, b, alpha_b, out):
"""Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8.
Uses pre-allocated uint16 scratch buffers (_u16_a, _u16_b).
Uses ``cv2.addWeighted`` (single SIMD-fused pass) when available;
falls back to a 6-pass numpy implementation using pre-allocated
uint16 scratch buffers (_u16_a, _u16_b).
"""
if _HAS_CV2:
w_b = alpha_b / 256.0
cv2.addWeighted(a, 1.0 - w_b, b, w_b, 0.0, dst=out)
return
nonlocal _u16_a, _u16_b
np.copyto(_u16_a, a, casting="unsafe")
np.copyto(_u16_b, b, casting="unsafe")
@@ -229,6 +247,7 @@ class PictureColorStripStream(ColorStripStream):
try:
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
limiter.begin()
frame_time = self._frame_time
@@ -236,10 +255,19 @@ class PictureColorStripStream(ColorStripStream):
frame = self._live_stream.get_latest_frame()
if frame is None or frame is cached_frame:
limiter.wait(frame_time)
# Event-driven wait on the source: returns
# immediately when a new frame lands, or after
# frame_time as a safety timeout.
elapsed = time.perf_counter() - loop_start
remaining = max(frame_time - elapsed, 0.0)
if remaining > 0:
last_source_id = self._live_stream.wait_for_new_frame(
last_source_id, timeout=remaining
)
continue
cached_frame = frame
last_source_id = self._live_stream.current_frame_id
t0 = time.perf_counter()
# Record the new frame in the rolling 1s window
@@ -255,12 +283,17 @@ class PictureColorStripStream(ColorStripStream):
mapper = self._pixel_mapper
if isinstance(mapper, AdvancedPixelMapper):
# Advanced mode: gather frames from all live streams
# Advanced mode: gather frames from all live streams.
# Reuse the already-sampled primary frame to avoid
# an extra lock acquisition on it.
frames_dict = {}
for ps_id, ls in self._live_streams.items():
f = ls.get_latest_frame()
if f is not None:
frames_dict[ps_id] = f
if ls is self._live_stream:
frames_dict[ps_id] = frame
else:
f = ls.get_latest_frame()
if f is not None:
frames_dict[ps_id] = f
t1 = time.perf_counter()
led_colors = mapper.map_lines_to_leds(frames_dict)
else:
@@ -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)
@@ -91,7 +91,10 @@ class DeviceTestModeMixin:
led_count=ds.led_count,
use_ddp=True,
)
deps = ProviderDeps(device_store=self._device_store)
deps = ProviderDeps(
device_store=self._device_store,
mqtt_manager=getattr(self, "_mqtt_manager", None),
)
client = create_led_client(config, deps=deps)
await client.connect()
self._idle_clients[device_id] = client
@@ -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:
@@ -400,17 +362,16 @@ class HALightTargetProcessor(TargetProcessor):
# Cache for WS preview (always, even if HA call is skipped)
self._latest_entity_colors[entity_id] = (r, g, b)
# Calculate brightness (0-255) from max channel
brightness = max(r, g, b)
# Brightness (0-255) is derived from the max channel and scaled once by
# the per-mapping brightness_scale × value-source multiplier. eff_scale
# may exceed 1.0 (a boosting value source), so we clamp at 255.
bs = (
mapping.brightness_scale.value
if hasattr(mapping.brightness_scale, "value")
else mapping.brightness_scale
)
eff_scale = bs * vs_multiplier
if eff_scale < 1.0:
brightness = int(brightness * eff_scale)
brightness = max(0, min(255, int(max(r, g, b) * eff_scale)))
should_be_on = (
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
@@ -430,7 +391,7 @@ class HALightTargetProcessor(TargetProcessor):
service_data = {
"rgb_color": [r, g, b],
"brightness": min(255, int(brightness * bs)),
"brightness": brightness,
}
transition_val = self._transition.value
if transition_val > 0:
@@ -523,6 +484,59 @@ class HALightTargetProcessor(TargetProcessor):
snap[eid] = state
return snap
def _unique_mapped_entity_ids(self) -> List[str]:
"""Return unique non-empty entity ids from configured mappings."""
entity_ids: List[str] = []
seen = set()
for mapping in self._light_mappings:
eid = mapping.entity_id
if eid and eid not in seen:
seen.add(eid)
entity_ids.append(eid)
return entity_ids
async def turn_off_lights(self) -> int:
"""Turn off every mapped HA light entity.
Works regardless of whether the processor is currently running.
If the HA runtime isn't already acquired, temporarily acquires it
from the manager so the call can succeed without starting the
target. Returns the number of entities the turn_off was issued for.
"""
entity_ids = self._unique_mapped_entity_ids()
if not entity_ids:
return 0
# Use existing runtime when running; otherwise borrow one from the
# manager via acquire/release so we don't keep a connection open.
ha_manager = getattr(self._ctx, "ha_manager", None)
runtime = self._ha_runtime
borrowed = False
if runtime is None:
if ha_manager is None or not self._ha_source_id:
raise RuntimeError("HA runtime not available")
runtime = await ha_manager.acquire(self._ha_source_id)
borrowed = True
try:
if not runtime.is_connected:
raise RuntimeError("HA not connected")
for eid in entity_ids:
await runtime.call_service(
domain="light",
service="turn_off",
service_data={},
target={"entity_id": eid},
)
finally:
if borrowed and ha_manager is not None:
try:
await ha_manager.release(self._ha_source_id)
except Exception:
pass
return len(entity_ids)
async def _apply_stop_action(self) -> None:
"""Run the configured finalization on stop."""
if self._stop_action == "none":
@@ -534,15 +548,7 @@ class HALightTargetProcessor(TargetProcessor):
)
return
# Unique entity ids (a target may map the same entity twice in theory)
entity_ids = []
seen = set()
for mapping in self._light_mappings:
eid = mapping.entity_id
if eid and eid not in seen:
seen.add(eid)
entity_ids.append(eid)
entity_ids = self._unique_mapped_entity_ids()
if not entity_ids:
return
@@ -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,
)
@@ -31,9 +31,17 @@ class LiveStream(ABC):
"""Abstract base for a runtime frame source.
A LiveStream produces frames at some frequency. Consumers call
get_latest_frame() to read the most recent frame (non-blocking).
get_latest_frame() to read the most recent frame (non-blocking),
or wait_for_new_frame() for event-driven consumption.
"""
def __init__(self) -> None:
# Monotonic frame counter incremented by ``_signal_new_frame``.
# Consumers track the last value they saw to detect new frames
# without busy-polling.
self._frame_id: int = 0
self._frame_cond: threading.Condition = threading.Condition()
@property
@abstractmethod
def target_fps(self) -> int:
@@ -60,6 +68,35 @@ class LiveStream(ABC):
ScreenCapture with image data (RGB), or None if no frame available yet.
"""
@property
def current_frame_id(self) -> int:
"""Current monotonic frame ID. Increments on each new frame."""
return self._frame_id
def _signal_new_frame(self) -> None:
"""Producer: increment frame ID and notify all waiting consumers."""
with self._frame_cond:
self._frame_id += 1
self._frame_cond.notify_all()
def wait_for_new_frame(self, last_seen_id: int, timeout: float) -> int:
"""Consumer: block until a frame newer than ``last_seen_id`` arrives.
Args:
last_seen_id: Last frame ID the caller has already processed.
timeout: Maximum seconds to wait. ``0`` returns immediately.
Returns:
Current ``frame_id`` (may equal ``last_seen_id`` if timed out).
"""
if timeout <= 0:
return self._frame_id
with self._frame_cond:
if self._frame_id != last_seen_id:
return self._frame_id
self._frame_cond.wait(timeout=timeout)
return self._frame_id
class ScreenCaptureLiveStream(LiveStream):
"""Live stream backed by a CaptureStream with a dedicated capture thread.
@@ -73,6 +110,7 @@ class ScreenCaptureLiveStream(LiveStream):
"""
def __init__(self, capture_stream: CaptureStream, fps: int):
super().__init__()
self._capture_stream = capture_stream
self._fps = fps
self._frame_time = 1.0 / fps if fps > 0 else 1.0
@@ -139,6 +177,7 @@ class ScreenCaptureLiveStream(LiveStream):
if frame is not None:
with self._frame_lock:
self._latest_frame = frame
self._signal_new_frame()
consecutive_errors = 0
else:
# Small sleep when no frame available to avoid CPU spinning
@@ -181,6 +220,7 @@ class ProcessedLiveStream(LiveStream):
source: LiveStream,
filters: List[PostprocessingFilter],
):
super().__init__()
self._source = source
self._filters = filters
self._image_pool = ImagePool()
@@ -234,15 +274,22 @@ class ProcessedLiveStream(LiveStream):
def _process_loop(self) -> None:
"""Background thread: poll source, apply filters, cache result."""
cached_source_frame: Optional[ScreenCapture] = None
# Ring buffer: 3 slots guarantees consumer finished with oldest buffer.
# At most 2 frames are in flight (one in _latest_frame, one being
# processed by a consumer), so the 3rd slot is always safe to reuse.
_ring: List[Optional[np.ndarray]] = [None, None, None]
# Ring buffer: 5 slots gives a safety margin for the multi-consumer
# case (multiple PictureColorStripStream/HA target threads can hold
# the same _latest_frame reference while we wrap around). At 60 FPS
# the 5-slot rotation gives any consumer ~83 ms to finish reading
# before the slot is overwritten — well above any realistic
# extract→map→smooth tick.
_RING_SIZE = 5
_ring: List[Optional[np.ndarray]] = [None] * _RING_SIZE
_ring_idx = 0
# Separate buffer for idle-tick source copies (not part of the ring buffer)
_idle_src_buf: Optional[np.ndarray] = None
fps = self.target_fps
frame_time = 1.0 / fps if fps > 0 else 1.0
# Track the source's frame_id so we can wait event-driven for new
# frames instead of polling + sleeping.
last_source_id = self._source.current_frame_id
try:
with high_resolution_timer():
@@ -278,13 +325,20 @@ class ProcessedLiveStream(LiveStream):
)
with self._frame_lock:
self._latest_frame = processed
self._signal_new_frame()
# Event-driven wait: blocks until source produces a
# new frame, with frame_time as a safety timeout.
elapsed = time.perf_counter() - loop_start
remaining = frame_time - elapsed
time.sleep(max(remaining, 0.001))
remaining = max(frame_time - elapsed, 0.0)
if remaining > 0:
last_source_id = self._source.wait_for_new_frame(
last_source_id, timeout=remaining
)
continue
cached_source_frame = source_frame
last_source_id = self._source.current_frame_id
# Reuse ring buffer slot instead of allocating a new copy each frame
src = source_frame.image
@@ -293,7 +347,7 @@ class ProcessedLiveStream(LiveStream):
if buf is None or buf.shape != (h, w, c):
buf = np.empty((h, w, c), dtype=np.uint8)
_ring[_ring_idx] = buf
_ring_idx = (_ring_idx + 1) % 3
_ring_idx = (_ring_idx + 1) % _RING_SIZE
np.copyto(buf, src)
image = buf
@@ -315,6 +369,7 @@ class ProcessedLiveStream(LiveStream):
)
with self._frame_lock:
self._latest_frame = processed
self._signal_new_frame()
except Exception as e:
logger.error(f"Filter processing error: {e}")
time.sleep(0.01)
@@ -328,9 +383,12 @@ class StaticImageLiveStream(LiveStream):
"""Live stream that always returns the same static image."""
def __init__(self, image: np.ndarray):
super().__init__()
self._image = image
h, w = image.shape[:2]
self._frame = ScreenCapture(image=image, width=w, height=h, display_index=-1)
# Bump frame_id once so consumers waiting on it see a "new" frame.
self._signal_new_frame()
@property
def target_fps(self) -> int:
@@ -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.
@@ -25,7 +31,13 @@ class MappedColorStripStream(ColorStripStream):
"""
def __init__(self, source, css_manager):
import uuid as _uuid
self._source_id: str = source.id
# Unique per instance so concurrent consumers don't collide on
# sub-stream consumer IDs (e.g. two preview WS connections against
# the same mapped source, or future fan-out to multiple targets).
self._instance_id: str = _uuid.uuid4().hex[:8]
self._zones: List[dict] = list(source.zones)
self._led_count: int = source.led_count
self._auto_size: bool = source.led_count == 0
@@ -40,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 ──────────────────────────────
@@ -127,7 +142,7 @@ class MappedColorStripStream(ColorStripStream):
src_id = zone.get("source_id", "")
if not src_id:
continue
consumer_id = f"{self._source_id}__zone_{i}"
consumer_id = f"{self._source_id}__{self._instance_id}__zone_{i}"
try:
stream = self._css_manager.acquire(src_id, consumer_id)
zone_len = self._zone_length(zone)
@@ -223,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)
@@ -22,11 +22,17 @@ class ProcessedColorStripStream(ColorStripStream):
"""
def __init__(self, source, css_manager, cspt_store=None):
import uuid as _uuid
self._source = source
self._css_manager = css_manager
self._cspt_store = cspt_store
self._input_stream: Optional[ColorStripStream] = None
self._consumer_id = f"__processed_{source.id}__"
# Unique per instance so concurrent consumers don't collide on
# sub-stream consumer IDs (e.g. two preview WS connections against
# the same processed source).
self._instance_id = _uuid.uuid4().hex[:8]
self._consumer_id = f"__processed_{source.id}__{self._instance_id}__"
self._filters = []
self._cached_template_id = None
self._running = False
@@ -97,9 +103,28 @@ class ProcessedColorStripStream(ColorStripStream):
return self._colors
def update_source(self, source) -> None:
old_input = self._source.input_source_id
new_input = source.input_source_id
self._source = source
# Force re-resolve filters on next iteration
self._cached_template_id = None
# If the input source changed while running, swap the acquired stream.
if self._running and new_input != old_input:
if self._input_stream and old_input:
try:
self._css_manager.release(old_input, self._consumer_id)
except Exception as e:
logger.warning(
f"Processed update: release of old input {old_input} failed: {e}"
)
self._input_stream = None
if new_input:
try:
self._input_stream = self._css_manager.acquire(new_input, self._consumer_id)
except Exception as e:
logger.warning(
f"Processed update: acquire of new input {new_input} failed: {e}"
)
def set_clock(self, clock_runtime) -> None:
if self._input_stream and hasattr(self._input_stream, "set_clock"):
@@ -135,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
@@ -71,8 +72,10 @@ class ProcessorDependencies:
weather_manager: Optional[WeatherManager] = None
asset_store: Optional[AssetStore] = None
ha_manager: Optional[Any] = None # HomeAssistantManager
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
@@ -168,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
@@ -175,6 +179,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# Wire value stream manager into CSS stream manager for composite layer brightness
self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
self._ha_manager = deps.ha_manager
self._mqtt_manager = deps.mqtt_manager
self._overlay_manager = OverlayManager()
self._event_queues: List[asyncio.Queue] = []
self._metrics_history = MetricsHistory(self)
@@ -223,6 +228,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
self._devices.get(did), "test_mode_active", False
),
ha_manager=self._ha_manager,
mqtt_manager=self._mqtt_manager,
)
# ===== EVENT SYSTEM (state change notifications) =====
@@ -465,6 +471,55 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
self._processors[target_id] = proc
logger.info(f"Registered HA light target: {target_id}")
def add_z2m_light_target(
self,
target_id: str,
mqtt_source_id: str,
source_kind: str = "css",
color_strip_source_id: str = "",
color_value_source_id: str = "",
brightness=None,
light_mappings=None,
base_topic: str = "zigbee2mqtt",
update_rate: float = 5.0,
transition=None,
min_brightness_threshold: int = 0,
color_tolerance: int = 5,
stop_action: str = "none",
) -> None:
"""Register a Zigbee2MQTT light target processor.
``mqtt_source_id`` references an entry in the MQTTSource store. The
processor will acquire/release that broker's runtime via the manager.
"""
if target_id in self._processors:
raise ValueError(f"Z2M light target {target_id} already registered")
if not mqtt_source_id:
raise ValueError("mqtt_source_id is required for Z2M light targets")
from ledgrab.core.processing.z2m_light_target_processor import (
Z2MLightTargetProcessor,
)
proc = Z2MLightTargetProcessor(
target_id=target_id,
mqtt_source_id=mqtt_source_id,
source_kind=source_kind,
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=brightness,
light_mappings=light_mappings or [],
base_topic=base_topic,
update_rate=update_rate,
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
stop_action=stop_action,
ctx=self._build_context(),
)
self._processors[target_id] = proc
logger.info(f"Registered Z2M light target: {target_id} -> mqtt[{mqtt_source_id}]")
def remove_target(self, target_id: str):
"""Unregister a target (any type)."""
if target_id not in self._processors:
@@ -755,6 +810,38 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
def is_css_overlay_active(self, css_id: str) -> bool:
return self._overlay_manager.is_running(css_id)
# ===== HA LIGHT — MANUAL TURN OFF =====
async def turn_off_ha_light_target(self, target_id: str) -> int:
"""Turn off all HA light entities mapped by the given target.
Works whether or not the target's processor is running. Returns the
number of entities the turn_off was issued for.
"""
from ledgrab.core.processing.ha_light_target_processor import HALightTargetProcessor
proc = self._get_processor(target_id)
if not isinstance(proc, HALightTargetProcessor):
raise ValueError(f"Target {target_id} is not an HA light target")
return await proc.turn_off_lights()
# ===== Z2M LIGHT — MANUAL TURN OFF =====
async def turn_off_z2m_light_target(self, target_id: str) -> int:
"""Publish OFF to all Z2M bulbs mapped by the given target.
Works whether or not the target's processor is running. Returns the
number of bulbs the turn-off was issued for.
"""
from ledgrab.core.processing.z2m_light_target_processor import (
Z2MLightTargetProcessor,
)
proc = self._get_processor(target_id)
if not isinstance(proc, Z2MLightTargetProcessor):
raise ValueError(f"Target {target_id} is not a Z2M light target")
return await proc.turn_off_lights()
# ===== WEBSOCKET (delegates to processor) =====
def add_ha_light_ws_client(self, target_id: str, ws) -> None:
@@ -766,6 +853,15 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
if proc:
proc.remove_ws_client(ws)
def add_z2m_light_ws_client(self, target_id: str, ws) -> None:
proc = self._get_processor(target_id)
proc.add_ws_client(ws)
def remove_z2m_light_ws_client(self, target_id: str, ws) -> None:
proc = self._processors.get(target_id)
if proc:
proc.remove_ws_client(ws)
def add_led_preview_client(self, target_id: str, ws) -> None:
proc = self._get_processor(target_id)
proc.add_led_preview_client(ws)
@@ -97,6 +97,7 @@ class TargetContext:
fire_event: Callable[[dict], None] = lambda e: None
is_test_mode_active: Callable[[str], bool] = lambda _: False
ha_manager: Optional[Any] = None # HomeAssistantManager (avoid circular import)
mqtt_manager: Optional[Any] = None # MQTTManager (avoid circular import)
# ---------------------------------------------------------------------------
@@ -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:
@@ -144,6 +171,7 @@ class VideoCaptureLiveStream(LiveStream):
target_fps: int = 30,
):
_require_cv2()
super().__init__()
self._original_url = url
self._resolved_url: Optional[str] = None
self._loop = loop
@@ -184,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
@@ -348,6 +380,7 @@ class VideoCaptureLiveStream(LiveStream):
sc = ScreenCapture(image=buf, width=w, height=h, display_index=-1)
with self._frame_lock:
self._latest_frame = sc
self._signal_new_frame()
except Exception as e:
consecutive_errors += 1
@@ -136,7 +136,10 @@ class WledTargetProcessor(TargetProcessor):
self._device_config = config
# Connect to LED device
deps = ProviderDeps(device_store=self._ctx.device_store)
deps = ProviderDeps(
device_store=self._ctx.device_store,
mqtt_manager=getattr(self._ctx, "mqtt_manager", None),
)
try:
self._led_client = create_led_client(config, deps=deps)
await self._led_client.connect()
@@ -221,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:
@@ -230,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:
@@ -845,6 +848,11 @@ class WledTargetProcessor(TargetProcessor):
prev_frame_ref = None
has_any_frame = False
_prev_brightness = -1 # force first send
# Cache max-pixel per frame ref. ``np.max`` over a 200-300 LED array
# at 60 fps × N targets is non-trivial; we only need to recompute it
# when the frame reference itself changes.
_max_pixel_cached: int = 0
_max_pixel_for_frame: object = None
# Pre-allocate brightness scratch (uint16 intermediate + uint8 output)
_bright_u16: Optional[np.ndarray] = None
@@ -1012,7 +1020,12 @@ class WledTargetProcessor(TargetProcessor):
# If below cutoff → snap to 0 (LEDs off).
_thresh = self._min_brightness_threshold
if _thresh > 0 and cur_brightness > 0:
max_pixel = int(np.max(frame))
if frame is _max_pixel_for_frame:
max_pixel = _max_pixel_cached
else:
max_pixel = int(np.max(frame))
_max_pixel_cached = max_pixel
_max_pixel_for_frame = frame
if max_pixel * cur_brightness // 255 < _thresh:
cur_brightness = 0
@@ -0,0 +1,567 @@
"""Zigbee2MQTT light target processor — publishes RGB+brightness directly to Z2M topics.
Reads from a ColorStripStream or a colour-returning ValueStream, averages LED segments
to single RGB values, and publishes ``{"color":{"r","g","b"}, "brightness", "transition"}``
payloads to ``<base_topic>/<friendly_name>/set`` on the shared MQTT broker.
Bypasses Home Assistant bulbs must already be paired with the Z2M coordinator.
"""
import asyncio
import json
import time
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,
Z2MLightMapping,
)
from ledgrab.utils import get_logger
logger = get_logger(__name__)
class Z2MLightTargetProcessor(TargetProcessor):
"""Streams averaged LED colors to Zigbee2MQTT bulbs via MQTT."""
def __init__(
self,
target_id: str,
mqtt_source_id: str,
source_kind: str = "css",
color_strip_source_id: str = "",
color_value_source_id: str = "",
brightness=None,
light_mappings: Optional[List[Z2MLightMapping]] = None,
base_topic: str = DEFAULT_Z2M_BASE_TOPIC,
update_rate: float = 5.0,
transition=None,
min_brightness_threshold: int = 0,
color_tolerance: int = 5,
stop_action: str = "none",
ctx: Optional[TargetContext] = None,
):
from ledgrab.storage.bindable import BindableFloat, bfloat
super().__init__(target_id, ctx)
self._mqtt_source_id = mqtt_source_id
self._source_kind = source_kind if source_kind in ("css", "color_vs") else "css"
self._css_id = color_strip_source_id
self._color_vs_id = color_value_source_id
if brightness is not None and isinstance(brightness, BindableFloat):
self._brightness = brightness
else:
self._brightness = BindableFloat(1.0)
if transition is not None and isinstance(transition, BindableFloat):
self._transition = transition
else:
self._transition = BindableFloat(float(transition) if transition is not None else 0.3)
self._light_mappings = light_mappings or []
self._base_topic = (base_topic or "").strip() or DEFAULT_Z2M_BASE_TOPIC
# Z2M ceiling — 10 Hz is the realistic Zigbee-mesh upper bound per bulb.
self._update_rate = max(0.5, min(10.0, bfloat(update_rate, 5.0)))
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
self._color_tolerance = int(bfloat(color_tolerance, 5.0))
self._stop_action = stop_action if stop_action in ("none", "turn_off") else "none"
# Runtime state
self._css_stream = None
self._color_stream = None
self._value_stream = None # brightness VS stream
self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
self._previous_on: Dict[str, bool] = {}
self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {}
self._ws_clients: List[Any] = []
self._start_time: Optional[float] = None
# MQTT runtime acquired from MQTTManager at start(); released at stop().
self._mqtt_runtime = None
# Track whether we hold an outstanding acquire() so stop() knows to release.
self._mqtt_acquired_id: Optional[str] = None
@property
def device_id(self) -> Optional[str]:
return None # Z2M targets don't use device providers
# ─────────── Lifecycle ───────────
async def start(self) -> None:
if 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 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"Z2M light {self._target_id}: failed to acquire CSS stream: {e}"
)
if self._brightness.source_id and self._ctx.value_stream_manager:
try:
self._value_stream = self._ctx.value_stream_manager.acquire(
self._brightness.source_id
)
except Exception as e:
logger.warning(f"Z2M light {self._target_id}: failed to acquire brightness VS: {e}")
self._value_stream = None
# Acquire the MQTT runtime for our broker source. If the manager is
# missing or the source can't be resolved, the runtime stays None and
# publishes are dropped — the loop guards on it.
mqtt_manager = getattr(self._ctx, "mqtt_manager", None)
if mqtt_manager is None:
logger.warning(
f"Z2M light {self._target_id}: no MQTT manager in context — frames will be dropped"
)
elif not self._mqtt_source_id:
logger.warning(
f"Z2M light {self._target_id}: no mqtt_source_id configured — frames will be dropped"
)
else:
try:
self._mqtt_runtime = await mqtt_manager.acquire(self._mqtt_source_id)
self._mqtt_acquired_id = self._mqtt_source_id
except Exception as e:
logger.warning(
f"Z2M light {self._target_id}: failed to acquire MQTT runtime "
f"for source {self._mqtt_source_id}: {e}"
)
self._is_running = True
self._start_time = time.monotonic()
self._task = asyncio.create_task(self._processing_loop())
logger.info(f"Z2M light target started: {self._target_id} -> mqtt[{self._mqtt_source_id}]")
async def stop(self) -> None:
self._is_running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
try:
await self._apply_stop_action()
except Exception as e:
logger.warning(
f"Z2M light {self._target_id}: stop_action '{self._stop_action}' failed: {e}"
)
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
if self._value_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._brightness.source_id)
except Exception:
pass
self._value_stream = None
# Release the MQTT runtime so the broker connection can be torn down
# if no other consumer holds it.
if self._mqtt_acquired_id is not None:
mqtt_manager = getattr(self._ctx, "mqtt_manager", None)
if mqtt_manager is not None:
try:
await mqtt_manager.release(self._mqtt_acquired_id)
except Exception as e:
logger.warning(
f"Z2M light {self._target_id}: failed to release MQTT runtime: {e}"
)
self._mqtt_acquired_id = None
self._mqtt_runtime = None
self._previous_colors.clear()
self._previous_on.clear()
self._latest_entity_colors.clear()
self._ws_clients.clear()
logger.info(f"Z2M light target stopped: {self._target_id}")
# ─────────── Settings ───────────
def update_settings(self, settings) -> None:
from ledgrab.storage.bindable import BindableFloat, bfloat
if not isinstance(settings, dict):
return
if "update_rate" in settings:
self._update_rate = max(0.5, min(10.0, bfloat(settings["update_rate"], 5.0)))
if "transition" in settings:
t = settings["transition"]
self._transition = (
t if isinstance(t, BindableFloat) else self._transition.apply_update(t)
)
if "brightness" in settings:
b = settings["brightness"]
self._brightness = (
b if isinstance(b, BindableFloat) else self._brightness.apply_update(b)
)
if "base_topic" in settings:
bt = (settings["base_topic"] or "").strip()
self._base_topic = bt or DEFAULT_Z2M_BASE_TOPIC
if "mqtt_source_id" in settings:
# The broker swap itself is deferred to a stop/restart cycle
# (acquire/release happens at start()/stop() boundaries). We
# record the new id so the next start() uses the right runtime.
new_id = settings["mqtt_source_id"] or ""
if new_id != self._mqtt_source_id:
self._mqtt_source_id = new_id
if "min_brightness_threshold" in settings:
self._min_brightness_threshold = int(bfloat(settings["min_brightness_threshold"], 0.0))
if "color_tolerance" in settings:
self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0))
if "light_mappings" in settings:
self._light_mappings = settings["light_mappings"]
if "stop_action" in settings:
sa = settings["stop_action"]
if sa in ("none", "turn_off"):
self._stop_action = sa
new_kind = settings.get("source_kind")
new_color_vs = settings.get("color_value_source_id")
kind_changed = new_kind in ("css", "color_vs") and new_kind != self._source_kind
color_vs_changed = new_color_vs is not None and new_color_vs != self._color_vs_id
if kind_changed or color_vs_changed:
self._swap_color_source(
new_kind if kind_changed else self._source_kind,
new_color_vs if new_color_vs is not None else self._color_vs_id,
)
def update_css_source(self, color_strip_source_id: str) -> None:
old_id = self._css_id
self._css_id = color_strip_source_id
if self._source_kind == "css" and self._is_running and self._ctx.color_strip_stream_manager:
try:
new_stream = self._ctx.color_strip_stream_manager.acquire(
color_strip_source_id, self._target_id
)
old_stream = self._css_stream
self._css_stream = new_stream
if old_stream:
self._ctx.color_strip_stream_manager.release(old_id, self._target_id)
except Exception as e:
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:
"""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()
# ─────────── WebSocket clients ───────────
def add_ws_client(self, ws: Any) -> None:
self._ws_clients.append(ws)
def remove_ws_client(self, ws: Any) -> None:
if ws in self._ws_clients:
self._ws_clients.remove(ws)
def supports_websocket(self) -> bool:
return True
# ─────────── State / metrics ───────────
def get_state(self) -> dict:
uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0
entity_colors = {
name: {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"}
for name, (r, g, b) in self._latest_entity_colors.items()
}
mqtt_connected = bool(self._mqtt_runtime and self._mqtt_runtime.is_connected)
return {
"target_id": self._target_id,
"processing": self._is_running,
"source_kind": self._source_kind,
"css_id": self._css_id,
"color_value_source_id": self._color_vs_id,
"mqtt_source_id": self._mqtt_source_id,
"is_running": self._is_running,
"mqtt_connected": mqtt_connected,
"base_topic": self._base_topic,
"light_count": len(self._light_mappings),
"update_rate": self._update_rate,
"fps_actual": self._update_rate if self._is_running else None,
"fps_target": self._update_rate,
"fps_capture": self._update_rate if self._is_running else None,
"uptime_seconds": uptime,
"entity_colors": entity_colors,
}
def get_metrics(self) -> dict:
uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0
return {
"target_id": self._target_id,
"processing": self._is_running,
"fps_actual": self._update_rate if self._is_running else None,
"fps_target": self._update_rate,
"uptime_seconds": uptime,
"frames_processed": 0,
"errors_count": 0,
"last_error": None,
"last_update": None,
}
# ─────────── Processing loop ───────────
async def _processing_loop(self) -> None:
interval = 1.0 / self._update_rate
while self._is_running:
try:
loop_start = time.monotonic()
if self._mqtt_runtime is not None and self._mqtt_runtime.is_connected:
if self._source_kind == "color_vs" and self._color_stream is not None:
try:
color = self._color_stream.get_color()
except Exception:
color = None
if isinstance(color, (list, tuple)) and len(color) >= 3:
await self._update_lights_single_color(
int(color[0]), int(color[1]), int(color[2])
)
elif self._css_stream is not None:
colors = self._css_stream.get_latest_colors()
if colors is not None and len(colors) > 0:
await self._update_lights(colors)
elapsed = time.monotonic() - loop_start
# Refresh interval each loop so update_rate changes take effect immediately.
interval = 1.0 / max(0.5, self._update_rate)
sleep_time = max(0.05, interval - elapsed)
await asyncio.sleep(sleep_time)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Z2M light {self._target_id} loop error: {e}")
await asyncio.sleep(1.0)
def _read_brightness_multiplier(self) -> float:
if self._value_stream is None:
return 1.0
try:
return float(self._value_stream.get_value())
except Exception:
return 1.0
def _topic_for(self, friendly_name: str) -> str:
# Z2M topic convention: <base_topic>/<friendly_name>/set
return f"{self._base_topic}/{friendly_name}/set"
async def _publish_payload(self, friendly_name: str, payload: dict) -> None:
if not friendly_name or self._mqtt_runtime is None:
return
try:
await self._mqtt_runtime.publish(
self._topic_for(friendly_name),
json.dumps(payload),
retain=False,
qos=0,
)
except Exception as e:
logger.debug(f"Z2M light {self._target_id}: publish failed for {friendly_name}: {e}")
async def _send_entity_color(
self,
mapping: Z2MLightMapping,
r: int,
g: int,
b: int,
vs_multiplier: float,
) -> None:
"""Apply tolerance/threshold gates and publish one Z2M command."""
friendly = mapping.friendly_name
if not friendly:
return
# Cache for WS preview (always, even if MQTT is skipped).
self._latest_entity_colors[friendly] = (r, g, b)
bs = (
mapping.brightness_scale.value
if hasattr(mapping.brightness_scale, "value")
else mapping.brightness_scale
)
eff_scale = bs * vs_multiplier
# Z2M uses 0..254 for `brightness` on Zigbee bulbs.
brightness = max(0, min(254, int(max(r, g, b) * eff_scale)))
should_be_on = (
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
)
prev_color = self._previous_colors.get(friendly)
was_on = self._previous_on.get(friendly, True)
if should_be_on:
if prev_color is not None and was_on:
dr = abs(r - prev_color[0])
dg = abs(g - prev_color[1])
db = abs(b - prev_color[2])
if max(dr, dg, db) < self._color_tolerance:
return # colour hasn't changed enough
payload: Dict[str, Any] = {
"state": "ON",
"color": {"r": r, "g": g, "b": b},
"brightness": brightness,
}
transition_val = self._transition.value
if transition_val > 0:
payload["transition"] = transition_val
await self._publish_payload(friendly, payload)
self._previous_colors[friendly] = (r, g, b)
self._previous_on[friendly] = True
elif was_on:
await self._publish_payload(friendly, {"state": "OFF"})
self._previous_on[friendly] = False
self._previous_colors.pop(friendly, None)
async def _update_lights(self, colors: np.ndarray) -> None:
led_count = len(colors)
vs_multiplier = self._read_brightness_multiplier()
for mapping in self._light_mappings:
if not mapping.friendly_name:
continue
start = max(0, mapping.led_start)
end = mapping.led_end if mapping.led_end >= 0 else led_count
end = min(end, led_count)
if start >= end:
continue
segment = colors[start:end]
avg = segment.mean(axis=0).astype(int)
await self._send_entity_color(
mapping, int(avg[0]), int(avg[1]), int(avg[2]), vs_multiplier
)
if self._ws_clients and self._latest_entity_colors:
await self._broadcast_entity_colors()
async def _update_lights_single_color(self, r: int, g: int, b: int) -> None:
vs_multiplier = self._read_brightness_multiplier()
for mapping in self._light_mappings:
if not mapping.friendly_name:
continue
await self._send_entity_color(mapping, r, g, b, vs_multiplier)
if self._ws_clients and self._latest_entity_colors:
await self._broadcast_entity_colors()
async def _broadcast_entity_colors(self) -> None:
colors_payload = {
name: {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"}
for name, (r, g, b) in self._latest_entity_colors.items()
}
message = json.dumps({"type": "colors_update", "colors": colors_payload})
dead: List[Any] = []
for ws in self._ws_clients:
try:
await ws.send_text(message)
except Exception:
dead.append(ws)
for ws in dead:
self._ws_clients.remove(ws)
# ─────────── Stop-action finalization ───────────
def _unique_mapped_friendly_names(self) -> List[str]:
names: List[str] = []
seen = set()
for m in self._light_mappings:
fn = m.friendly_name
if fn and fn not in seen:
seen.add(fn)
names.append(fn)
return names
async def turn_off_lights(self) -> int:
"""Publish ``{"state":"OFF"}`` to every mapped bulb.
Works whether or not the processor is running. When the processor
isn't running, temporarily borrows the runtime from the MQTT manager
and releases it afterwards. Returns the number of bulbs the
turn-off was issued for.
"""
names = self._unique_mapped_friendly_names()
if not names:
return 0
mqtt_manager = getattr(self._ctx, "mqtt_manager", None)
if mqtt_manager is None:
raise RuntimeError("MQTT manager not available")
if not self._mqtt_source_id:
raise RuntimeError("Target has no MQTT source configured")
# Use the already-acquired runtime when running; otherwise borrow one.
runtime = self._mqtt_runtime
borrowed = False
if runtime is None:
runtime = await mqtt_manager.acquire(self._mqtt_source_id)
borrowed = True
try:
if not runtime.is_connected:
raise RuntimeError("MQTT broker not connected")
# Swap in the borrowed runtime so _publish_payload uses it.
prev_runtime = self._mqtt_runtime
self._mqtt_runtime = runtime
try:
for name in names:
await self._publish_payload(name, {"state": "OFF"})
finally:
self._mqtt_runtime = prev_runtime
finally:
if borrowed:
try:
await mqtt_manager.release(self._mqtt_source_id)
except Exception as e:
logger.warning(
f"Z2M light {self._target_id}: failed to release borrowed runtime: {e}"
)
return len(names)
async def _apply_stop_action(self) -> None:
if self._stop_action == "none":
return
if self._mqtt_runtime is None or not self._mqtt_runtime.is_connected:
logger.info(
f"Z2M light {self._target_id}: skipping stop_action "
f"'{self._stop_action}' — MQTT not connected"
)
return
if self._stop_action == "turn_off":
for name in self._unique_mapped_friendly_names():
await self._publish_payload(name, {"state": "OFF"})
+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)
+124 -59
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
@@ -48,13 +50,12 @@ from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
from ledgrab.core.game_integration.community_loader import register_community_adapters
from ledgrab.core.mqtt.mqtt_service import MQTTService
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
from ledgrab.core.devices.mqtt_client import set_mqtt_service
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.core.processing.os_notification_listener import OsNotificationListener
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
@@ -71,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.
@@ -167,6 +172,8 @@ weather_manager = WeatherManager(weather_source_store)
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)
@@ -189,8 +196,10 @@ processor_manager = ProcessorManager(
weather_manager=weather_manager,
asset_store=asset_store,
ha_manager=ha_manager,
mqtt_manager=mqtt_manager,
game_event_bus=game_event_bus,
audio_processing_template_store=audio_processing_template_store,
http_endpoint_store=http_endpoint_store,
)
)
@@ -238,23 +247,28 @@ async def lifespan(app: FastAPI):
client_labels = ", ".join(config.auth.api_keys.keys())
logger.info(f"Authorized clients: {client_labels}")
# Create MQTT service (shared broker connection legacy, used by MQTTLEDClient)
mqtt_service = MQTTService(config.mqtt)
set_mqtt_service(mqtt_service)
# One-shot migration: legacy global ``mqtt:`` config block → first MQTTSource.
# No-op once the store has any entries.
try:
from ledgrab.core.mqtt.legacy_migration import migrate_legacy_mqtt_config
# Create MQTT manager (multi-source, ref-counted — new entity-based model)
mqtt_manager = MQTTManager(mqtt_source_store)
migrate_legacy_mqtt_config(mqtt_source_store)
except Exception as e:
logger.error("Legacy MQTT migration failed: %s", e)
# Create automation engine (needs processor_manager + mqtt + 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,
mqtt_service=mqtt_service,
scene_preset_store=scene_preset_store,
target_store=output_target_store,
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
@@ -308,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,
)
@@ -347,9 +362,6 @@ async def lifespan(app: FastAPI):
# Start background health monitoring for all devices
await processor_manager.start_health_monitoring()
# Start MQTT service (broker connection for output, triggers, state)
await mqtt_service.start()
# Start automation engine (evaluates conditions and activates scenes)
await automation_engine.start()
@@ -387,39 +399,50 @@ 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()
except Exception as e:
logger.error(f"Error stopping OS notification listener: {e}")
# Stop all processing BEFORE tearing down ha_manager / mqtt_manager /
# mqtt_service. HA-light targets need a live HA runtime to apply their
# stop_action (turn_off / restore), and MQTT-output devices need a live
# MQTT broker connection to send restore frames. Shutting those down
# first silently turns "stop_targets" into a no-op for those targets.
# Stop all processing BEFORE tearing down ha_manager / mqtt_manager.
# HA-light targets need a live HA runtime to apply their stop_action
# (turn_off / restore), and MQTT-output targets need a live broker
# runtime to send restore frames. Shutting those down first silently
# turns "stop_targets" into a no-op for those targets.
#
# The shutdown_action setting controls whether per-device restore
# frames are sent: "stop_targets" (default) runs the normal stop
@@ -434,27 +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}")
try:
await mqtt_service.stop()
except Exception as e:
logger.error(f"Error stopping MQTT service: {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:
@@ -462,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
@@ -528,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()
+448 -7
View File
@@ -732,21 +732,198 @@ textarea:focus-visible {
.ws-url-row .btn { padding: 4px 10px; min-width: 0; flex: 0 0 auto; }
.endpoint-label { display: block; font-weight: 600; margin-bottom: 2px; opacity: 0.7; font-size: 0.8em; }
/* Scene target selector */
/* Scene target selector patch-bay channel slots, paired with the
.ds-section[data-ch="cyan"] panel in scene-preset-editor.html.
Each item reads like a numbered channel: index · icon plate · name +
type chip · remove. Slot indices are rendered via a CSS counter so
DOM reorders don't need JS renumbering. */
.scene-target-list {
--st-ch: var(--ch-cyan, var(--info-color, #00d8ff));
counter-reset: st-slot;
display: flex;
flex-direction: column;
gap: 4px;
padding: 0;
}
.scene-target-list:empty::before {
content: attr(data-empty);
display: block;
padding: 14px 12px;
font-size: 0.78rem;
color: var(--lux-ink-dim, var(--text-secondary));
border: 1px dashed color-mix(in srgb, var(--st-ch) 40%, var(--lux-line, var(--border-color)));
border-radius: var(--lux-r-md, 6px);
background:
repeating-linear-gradient(135deg,
color-mix(in srgb, var(--st-ch) 4%, transparent) 0 6px,
transparent 6px 12px);
text-align: center;
letter-spacing: 0.04em;
}
.scene-target-item {
counter-increment: st-slot;
position: relative;
display: grid;
grid-template-columns: 26px 32px 1fr auto;
align-items: center;
gap: 10px;
padding: 6px 8px 6px 6px;
border: 1px solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 6px);
background:
linear-gradient(180deg,
color-mix(in srgb, var(--st-ch) 3%, var(--lux-bg-2, var(--bg-secondary))) 0%,
color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 70%, transparent) 100%);
font-size: 0.85rem;
transition:
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
}
.scene-target-item:hover {
border-color: color-mix(in srgb, var(--st-ch) 55%, var(--lux-line, var(--border-color)));
box-shadow:
inset 2px 0 0 color-mix(in srgb, var(--st-ch) 80%, transparent),
0 1px 0 color-mix(in srgb, var(--st-ch) 14%, transparent);
}
.scene-target-item::before {
content: counter(st-slot, decimal-leading-zero);
grid-column: 1;
justify-self: center;
font-family: var(--font-mono);
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--st-ch) 75%, var(--lux-ink-dim, var(--text-secondary)));
opacity: 0.85;
}
.scene-target-icon {
grid-column: 2;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
background: var(--bg-secondary);
font-size: 0.9rem;
justify-content: center;
color: var(--st-ch);
background: color-mix(in srgb, var(--st-ch) 9%, transparent);
border: 1px solid color-mix(in srgb, var(--st-ch) 22%, transparent);
border-radius: 5px;
flex-shrink: 0;
}
.scene-target-icon svg,
.scene-target-icon .icon { width: 18px; height: 18px; }
.scene-target-id {
grid-column: 3;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.scene-target-name {
font-weight: 600;
color: var(--lux-ink, var(--text-color));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
}
.scene-target-type {
align-self: flex-start;
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: color-mix(in srgb, var(--st-ch) 70%, var(--lux-ink-dim, var(--text-secondary)));
padding: 1px 5px;
border: 1px solid color-mix(in srgb, var(--st-ch) 28%, transparent);
border-radius: 2px;
line-height: 1.2;
}
.scene-target-remove {
grid-column: 4;
background: none;
border: 1px solid transparent;
color: var(--lux-ink-dim, var(--text-secondary));
width: 26px;
height: 26px;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0.7;
transition:
opacity 0.15s ease,
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
}
.scene-target-remove:hover,
.scene-target-remove:focus-visible {
opacity: 1;
color: var(--ch-coral, var(--danger-color, #ff5e5e));
background: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 10%, transparent);
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 35%, transparent);
outline: none;
}
.scene-target-remove .icon,
.scene-target-remove svg { width: 14px; height: 14px; }
/* Add-target slot full-width dashed cyan channel, reads like an
empty patch-bay slot waiting for an insertion. */
.scene-target-add-slot {
--st-ch: var(--ch-cyan, var(--info-color, #00d8ff));
margin-top: 8px;
width: 100%;
padding: 9px 12px;
background:
repeating-linear-gradient(135deg,
color-mix(in srgb, var(--st-ch) 5%, transparent) 0 6px,
transparent 6px 12px);
border: 1px dashed color-mix(in srgb, var(--st-ch) 50%, var(--lux-line, var(--border-color)));
border-radius: var(--lux-r-md, 6px);
color: color-mix(in srgb, var(--st-ch) 80%, var(--lux-ink, var(--text-color)));
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease,
transform 0.15s ease;
}
.scene-target-add-slot::before {
content: '+';
font-family: var(--font-mono);
font-size: 1.05rem;
font-weight: 700;
line-height: 1;
}
.scene-target-add-slot:hover:not(:disabled) {
background:
repeating-linear-gradient(135deg,
color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px,
transparent 6px 12px);
border-color: color-mix(in srgb, var(--st-ch) 75%, transparent);
color: var(--st-ch);
}
.scene-target-add-slot:active:not(:disabled) {
transform: translateY(1px);
}
.scene-target-add-slot:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Icon Select (reusable type picker) ──────────────────────── */
@@ -868,6 +1045,79 @@ textarea:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary));
}
/* ── MiniSelect (icon-less compact dropdown) ── */
.mini-select-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
min-height: 28px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease;
}
.mini-select-trigger:hover,
.mini-select-trigger:focus-visible {
border-color: var(--primary-color);
}
.mini-select-trigger-arrow {
opacity: 0.6;
font-size: 0.75em;
}
.mini-select-popup {
position: fixed;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
z-index: 9999;
overflow-y: auto;
display: none;
padding: 4px;
min-width: 120px;
}
.mini-select-popup.open { display: block; }
.mini-select-popup:focus,
.mini-select-popup:focus-visible {
outline: none;
}
.mini-select-option {
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
color: var(--text-primary);
font-size: 0.85rem;
user-select: none;
}
.mini-select-option:hover {
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
}
.mini-select-option.active {
color: var(--primary-color);
font-weight: 600;
}
.mini-select-option.focused {
background: color-mix(in srgb, var(--primary-color) 20%, transparent);
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary-color) 45%, transparent);
}
.icon-select-cell.focused {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 30%, transparent);
}
/* When the keyboard cursor lands on the currently-selected cell, intensify
the focus ring so the combined active+focused state is unambiguous. */
.icon-select-cell.active.focused {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 55%, transparent);
}
.icon-select-popup:focus,
.icon-select-popup:focus-visible {
outline: none;
}
.icon-select-cell-icon {
display: flex;
@@ -1451,3 +1701,194 @@ textarea:focus-visible {
height: 16px;
fill: currentColor;
}
/* Pair Device Modal
Reusable handshake UI for drivers that require a physical pairing
action (Nanoleaf, Tuya, Twinkly, ). See `features/pairing-flow.ts`
and `modals/pair-device.html`. The instructions stay visible across
states, dimming during pairing so the user remains oriented while
the progress ring takes focal point. */
.pair-instructions {
color: var(--lux-ink-dim, var(--text-secondary));
font-size: 0.92rem;
line-height: 1.5;
margin: 0 0 18px 0;
transition: opacity 0.25s ease;
}
.pair-states[data-pair-state="pairing"] ~ .pair-instructions,
.pair-states[data-pair-state="pairing"] .pair-instructions {
/* Selector kept for posterity actual dimming is applied via the
parent attribute below. */
}
.modal-body:has(.pair-states[data-pair-state="pairing"]) .pair-instructions {
opacity: 0.6;
}
.pair-states {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
padding: 8px 0 4px;
min-height: 200px;
}
.pair-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
width: 100%;
animation: pairFade 0.28s ease both;
}
@keyframes pairFade {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.pair-action {
min-width: 180px;
justify-content: center;
}
/* ── Idle visual: device-glyph with a soft pulse halo ────────── */
.pair-visual {
position: relative;
width: 96px;
height: 96px;
display: flex;
align-items: center;
justify-content: center;
}
.pair-visual-glyph {
width: 56px;
height: 56px;
color: var(--ch-signal, var(--primary-color));
opacity: 0.85;
}
.pair-pulse {
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(
circle at center,
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 28%, transparent),
transparent 70%
);
animation: pairPulse 2.2s ease-in-out infinite;
}
@keyframes pairPulse {
0%, 100% { transform: scale(0.85); opacity: 0.55; }
50% { transform: scale(1.08); opacity: 1; }
}
/* ── Pairing ring + countdown ────────────────────────────────── */
.pair-ring-wrap {
position: relative;
width: 140px;
height: 140px;
}
.pair-ring {
width: 100%;
height: 100%;
/* Rotate so the fill starts at 12 o'clock and progresses clockwise. */
transform: rotate(-90deg);
}
.pair-ring-bg {
stroke: color-mix(in srgb, var(--lux-line-bold, var(--border-color)) 60%, transparent);
}
.pair-ring-fg {
stroke: var(--ch-signal, var(--primary-color));
stroke-dasharray: 0 100;
transition: stroke-dasharray 0.18s linear;
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent));
}
.pair-ring-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
pointer-events: none;
}
.pair-ring-count {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 1.9rem;
font-weight: 500;
color: var(--lux-ink, var(--text-color));
letter-spacing: 0.02em;
line-height: 1;
}
.pair-ring-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--lux-ink-dim, var(--text-secondary));
}
/* ── Status banners (not-ready / success / failed) ───────────── */
.pair-status {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid var(--lux-line-bold, var(--border-color));
background: color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 80%, transparent);
font-size: 0.92rem;
line-height: 1.45;
color: var(--lux-ink, var(--text-color));
}
.pair-status .icon {
flex: 0 0 auto;
width: 22px;
height: 22px;
margin-top: 1px;
}
.pair-status-ok {
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent);
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent);
}
.pair-status-ok .icon { color: var(--ch-signal, var(--primary-color)); }
.pair-status-warn {
border-color: color-mix(in srgb, var(--ch-amber, #d99a00) 55%, transparent);
background: color-mix(in srgb, var(--ch-amber, #d99a00) 12%, transparent);
}
.pair-status-warn .icon { color: var(--ch-amber, #d99a00); }
.pair-status-err {
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 55%, transparent);
background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 12%, transparent);
}
.pair-status-err .icon { color: var(--ch-coral, var(--danger-color)); }
@media (prefers-reduced-motion: reduce) {
.pair-pulse,
.pair-state { animation: none; }
.pair-ring-fg { transition: none; }
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -147,6 +147,14 @@ export function isDmxDevice(type: string) {
return type === 'dmx';
}
export function isDdpDevice(type: string) {
return type === 'ddp';
}
export function isOpcDevice(type: string) {
return type === 'opc';
}
export function isEspnowDevice(type: string) {
return type === 'espnow';
}
@@ -155,6 +163,26 @@ export function isHueDevice(type: string) {
return type === 'hue';
}
export function isYeelightDevice(type: string) {
return type === 'yeelight';
}
export function isWizDevice(type: string) {
return type === 'wiz';
}
export function isLifxDevice(type: string) {
return type === 'lifx';
}
export function isGoveeDevice(type: string) {
return type === 'govee';
}
export function isNanoleafDevice(type: string) {
return type === 'nanoleaf';
}
export function isUsbhidDevice(type: string) {
return type === 'usbhid';
}
@@ -316,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
@@ -20,7 +20,12 @@ export type IconCategory =
| 'rooms'
| 'media'
| 'signal'
| 'ambience';
| 'ambience'
| 'weather'
| 'nature'
| 'controls'
| 'status'
| 'office';
export interface DeviceIconDef {
/** Stable identifier — persisted on the entity. */
@@ -42,6 +47,11 @@ export const CATEGORIES: { id: IconCategory; label: string; i18n: string }[] = [
{ id: 'media', label: 'Media', i18n: 'device.icon.cat.media' },
{ id: 'signal', label: 'Signal', i18n: 'device.icon.cat.signal' },
{ id: 'ambience', label: 'Ambience', i18n: 'device.icon.cat.ambience' },
{ id: 'weather', label: 'Weather', i18n: 'device.icon.cat.weather' },
{ id: 'nature', label: 'Nature', i18n: 'device.icon.cat.nature' },
{ id: 'controls', label: 'Controls', i18n: 'device.icon.cat.controls' },
{ id: 'status', label: 'Status', i18n: 'device.icon.cat.status' },
{ id: 'office', label: 'Office', i18n: 'device.icon.cat.office' },
];
export const DEVICE_ICONS: DeviceIconDef[] = [
@@ -56,6 +66,16 @@ export const DEVICE_ICONS: DeviceIconDef[] = [
{ id: 'headphones', paths: P.headphones, label: 'Headphones', aliases: ['headset', 'cans'], category: 'hardware' },
{ id: 'usb', paths: P.usb, label: 'USB', aliases: ['cable', 'connector'], category: 'hardware' },
{ id: 'plug', paths: P.plug, label: 'Power plug', aliases: ['outlet', 'socket'], category: 'hardware' },
{ id: 'phone', paths: P.smartphone, label: 'Phone', aliases: ['mobile', 'tablet', 'cell', 'smartphone'], category: 'hardware' },
{ id: 'package', paths: P.packageIcon, label: 'Device unit', aliases: ['box', 'package', 'module', 'crate'], category: 'hardware' },
{ id: 'code', paths: P.code, label: 'Code', aliases: ['script', 'firmware', 'dev'], category: 'hardware' },
{ id: 'laptop', paths: P.laptop, label: 'Laptop', aliases: ['notebook', 'macbook', 'computer'], category: 'hardware' },
{ id: 'server', paths: P.server, label: 'Server', aliases: ['rack', 'host', 'cluster', 'nas'], category: 'hardware' },
{ id: 'router', paths: P.router, label: 'Router', aliases: ['network', 'gateway', 'access point', 'ap'], category: 'hardware' },
{ id: 'webcam', paths: P.webcam, label: 'Webcam', aliases: ['cam', 'capture', 'video'], category: 'hardware' },
{ id: 'bot', paths: P.bot, label: 'Bot', aliases: ['robot', 'vacuum', 'automation', 'roomba'], category: 'hardware' },
{ id: 'watch', paths: P.watch, label: 'Smartwatch', aliases: ['wearable', 'wrist', 'fitness'], category: 'hardware' },
{ id: 'mcu', paths: P.memoryStick, label: 'Microcontroller', aliases: ['esp32', 'esp8266', 'arduino', 'mcu', 'chip', 'firmware'], category: 'hardware' },
// Lighting
{ id: 'bulb', paths: P.lightbulb, label: 'Bulb', aliases: ['lamp', 'light', 'lightbulb'], category: 'lighting' },
@@ -65,6 +85,15 @@ export const DEVICE_ICONS: DeviceIconDef[] = [
{ id: 'lamp', paths: P.flaskConical, label: 'Floor lamp', aliases: ['standing', 'pendant'], category: 'lighting' },
{ id: 'power', paths: P.power, label: 'Power', aliases: ['onoff', 'switch', 'standby'], category: 'lighting' },
{ id: 'palette', paths: P.palette, label: 'Palette', aliases: ['color', 'colour', 'paint'], category: 'lighting' },
{ id: 'dim', paths: P.sunDim, label: 'Dim light', aliases: ['soft', 'low', 'mood'], category: 'lighting' },
{ id: 'ring', paths: P.circle, label: 'Ring light', aliases: ['halo', 'round', 'circle'], category: 'lighting' },
{ id: 'point', paths: P.circleDot, label: 'Point light', aliases: ['pinpoint', 'dot', 'pixel'], category: 'lighting' },
{ id: 'ceiling', paths: P.lampCeiling, label: 'Ceiling light', aliases: ['chandelier', 'pendant', 'hanging'], category: 'lighting' },
{ id: 'desk_lamp', paths: P.lampDesk, label: 'Desk lamp', aliases: ['task', 'workspace', 'pixar'], category: 'lighting' },
{ id: 'wall_light', paths: P.lampWallUp, label: 'Wall light', aliases: ['sconce', 'uplight', 'mounted'], category: 'lighting' },
{ id: 'flashlight', paths: P.flashlight, label: 'Flashlight', aliases: ['torch', 'beam', 'handheld'], category: 'lighting' },
{ id: 'bulb_off', paths: P.lightbulbOff, label: 'Bulb off', aliases: ['lightoff', 'dark', 'inactive', 'disabled'], category: 'lighting' },
{ id: 'candle', paths: P.candle, label: 'Candle', aliases: ['flame', 'wax', 'ambient', 'romantic'], category: 'lighting' },
// Rooms
{ id: 'bed', paths: P.bed, label: 'Bedroom', aliases: ['sleep', 'bedroom'], category: 'rooms' },
@@ -74,6 +103,10 @@ export const DEVICE_ICONS: DeviceIconDef[] = [
{ id: 'home', paths: P.home, label: 'Home', aliases: ['house', 'household'], category: 'rooms' },
{ id: 'fan', paths: P.fan, label: 'Fan', aliases: ['cooling', 'air'], category: 'rooms' },
{ id: 'thermostat', paths: P.thermometer, label: 'Thermostat', aliases: ['temperature', 'heating', 'climate'], category: 'rooms' },
{ id: 'kitchen', paths: P.chefHat, label: 'Kitchen', aliases: ['cook', 'chef', 'food'], category: 'rooms' },
{ id: 'bath', paths: P.bath, label: 'Bathroom', aliases: ['bath', 'tub', 'shower'], category: 'rooms' },
{ id: 'garage', paths: P.warehouse, label: 'Garage', aliases: ['workshop', 'shed', 'storage'], category: 'rooms' },
{ id: 'outdoor', paths: P.trees, label: 'Outdoor', aliases: ['garden', 'yard', 'patio', 'park'], category: 'rooms' },
// Media
{ id: 'monitor', paths: P.monitor, label: 'Monitor', aliases: ['display', 'screen'], category: 'media' },
@@ -83,6 +116,11 @@ export const DEVICE_ICONS: DeviceIconDef[] = [
{ id: 'speaker', paths: P.volume2, label: 'Speaker', aliases: ['audio', 'output', 'monitor'], category: 'media' },
{ id: 'music', paths: P.music, label: 'Music', aliases: ['note', 'audio'], category: 'media' },
{ id: 'film', paths: P.film, label: 'Film', aliases: ['video', 'movie', 'reel'], category: 'media' },
{ id: 'projector', paths: P.projector, label: 'Projector', aliases: ['beamer', 'cinema', 'home theater'], category: 'media' },
{ id: 'camcorder', paths: P.video, label: 'Camcorder', aliases: ['video', 'recorder', 'cam'], category: 'media' },
{ id: 'disc', paths: P.disc, label: 'Disc', aliases: ['vinyl', 'record', 'cd', 'dvd'], category: 'media' },
{ id: 'image', paths: P.image, label: 'Image', aliases: ['picture', 'photo', 'still'], category: 'media' },
{ id: 'audio_file', paths: P.fileAudio, label: 'Audio file', aliases: ['sound', 'sample', 'track'], category: 'media' },
// Signal
{ id: 'wifi', paths: P.wifi, label: 'Wi-Fi', aliases: ['wireless', 'network'], category: 'signal' },
@@ -91,16 +129,73 @@ export const DEVICE_ICONS: DeviceIconDef[] = [
{ id: 'globe', paths: P.globe, label: 'Network', aliases: ['internet', 'web', 'world'], category: 'signal' },
{ id: 'cloud', paths: P.cloudSun, label: 'Cloud', aliases: ['weather', 'mqtt'], category: 'signal' },
{ id: 'gps', paths: P.mapPin, label: 'Location', aliases: ['map', 'gps', 'pin', 'place'], category: 'signal' },
{ id: 'send', paths: P.send, label: 'Send', aliases: ['push', 'publish', 'mqtt', 'transmit'], category: 'signal' },
// Ambience
{ id: 'sun', paths: P.sun, label: 'Sun', aliases: ['daylight', 'sunny', 'bright'], category: 'ambience' },
{ id: 'moon', paths: P.moon, label: 'Moon', aliases: ['night', 'dark'], category: 'ambience' },
{ id: 'flame', paths: P.flame, label: 'Flame', aliases: ['fire', 'candle', 'warm'], category: 'ambience' },
{ id: 'leaf', paths: P.leaf, label: 'Leaf', aliases: ['plant', 'eco', 'nature', 'green'], category: 'ambience' },
{ id: 'star', paths: P.star, label: 'Star', aliases: ['favorite', 'special'], category: 'ambience' },
{ id: 'sparkles', paths: P.sparkles, label: 'Sparkles', aliases: ['effect', 'magic', 'glow'], category: 'ambience' },
{ id: 'gamepad', paths: P.gamepad2, label: 'Game', aliases: ['gaming', 'play'], category: 'ambience' },
{ id: 'heart', paths: P.heart, label: 'Heart', aliases: ['love', 'favorite'], category: 'ambience' },
// Weather
{ id: 'rain', paths: P.cloudRain, label: 'Rain', aliases: ['rainy', 'shower', 'drizzle', 'wet'], category: 'weather' },
{ id: 'snow', paths: P.cloudSnow, label: 'Snow', aliases: ['snowy', 'blizzard', 'cold'], category: 'weather' },
{ id: 'thunder', paths: P.cloudLightning, label: 'Thunder', aliases: ['lightning', 'storm', 'bolt'], category: 'weather' },
{ id: 'fog', paths: P.cloudFog, label: 'Fog', aliases: ['mist', 'haze', 'foggy'], category: 'weather' },
{ id: 'wind', paths: P.wind, label: 'Wind', aliases: ['breeze', 'gust', 'windy'], category: 'weather' },
{ id: 'snowflake', paths: P.snowflake, label: 'Snowflake', aliases: ['frost', 'ice', 'crystal'], category: 'weather' },
{ id: 'umbrella', paths: P.umbrella, label: 'Umbrella', aliases: ['rain', 'shelter', 'parasol'], category: 'weather' },
{ id: 'sunrise', paths: P.sunrise, label: 'Sunrise', aliases: ['dawn', 'morning', 'sunup'], category: 'weather' },
{ id: 'sunset', paths: P.sunset, label: 'Sunset', aliases: ['dusk', 'evening', 'twilight'], category: 'weather' },
// Nature
{ id: 'tree', paths: P.treeDeciduous, label: 'Tree', aliases: ['plant', 'oak', 'forest', 'wood'], category: 'nature' },
{ id: 'flower', paths: P.flower2, label: 'Flower', aliases: ['bloom', 'blossom', 'garden'], category: 'nature' },
{ id: 'mountain', paths: P.mountain, label: 'Mountain', aliases: ['peak', 'hill', 'summit', 'alpine'], category: 'nature' },
{ id: 'waves', paths: P.waves, label: 'Waves', aliases: ['water', 'ocean', 'sea', 'ripple'], category: 'nature' },
{ id: 'sprout', paths: P.sprout, label: 'Sprout', aliases: ['seedling', 'shoot', 'grow', 'eco'], category: 'nature' },
{ id: 'water_drops', paths: P.droplets, label: 'Water drops', aliases: ['humidity', 'liquid', 'wet', 'splash'], category: 'nature' },
{ id: 'leaf', paths: P.leaf, label: 'Leaf', aliases: ['plant', 'eco', 'nature', 'green'], category: 'nature' },
// Controls
{ id: 'switch', paths: P.toggleRight, label: 'Switch', aliases: ['toggle', 'on', 'off'], category: 'controls' },
{ id: 'slider', paths: P.slidersHorizontal, label: 'Slider', aliases: ['mixer', 'eq', 'level', 'dimmer'], category: 'controls' },
{ id: 'settings', paths: P.settings, label: 'Settings', aliases: ['cog', 'preferences', 'gear'], category: 'controls' },
{ id: 'refresh', paths: P.refreshCw, label: 'Refresh', aliases: ['reload', 'sync', 'cycle'], category: 'controls' },
{ id: 'undo', paths: P.undo2, label: 'Undo', aliases: ['back', 'revert'], category: 'controls' },
{ id: 'trash', paths: P.trash2, label: 'Trash', aliases: ['delete', 'remove', 'bin'], category: 'controls' },
{ id: 'link', paths: P.link, label: 'Link', aliases: ['chain', 'connect', 'url'], category: 'controls' },
{ id: 'search', paths: P.search, label: 'Search', aliases: ['find', 'magnify', 'lookup'], category: 'controls' },
{ id: 'add', paths: P.plus, label: 'Add', aliases: ['plus', 'new', 'create'], category: 'controls' },
{ id: 'show', paths: P.eye, label: 'Show', aliases: ['view', 'visible', 'preview'], category: 'controls' },
{ id: 'lock', paths: P.lock, label: 'Lock', aliases: ['secure', 'private', 'closed'], category: 'controls' },
{ id: 'key', paths: P.keyRound, label: 'Key', aliases: ['credential', 'token', 'auth'], category: 'controls' },
{ id: 'tool', paths: P.wrench, label: 'Tool', aliases: ['wrench', 'fix', 'maintenance'], category: 'controls' },
// Status
{ id: 'check', paths: P.check, label: 'Check', aliases: ['done', 'ok', 'tick'], category: 'status' },
{ id: 'ok', paths: P.circleCheck, label: 'OK', aliases: ['success', 'valid', 'good'], category: 'status' },
{ id: 'warning', paths: P.triangleAlert, label: 'Warning', aliases: ['caution', 'alert', 'attention'], category: 'status' },
{ id: 'help', paths: P.circleHelp, label: 'Help', aliases: ['question', 'unknown', 'about'], category: 'status' },
{ id: 'off', paths: P.circleOff, label: 'Disabled', aliases: ['disable', 'inactive', 'banned'], category: 'status' },
{ id: 'shield', paths: P.shield, label: 'Shield', aliases: ['secure', 'protect', 'guard'], category: 'status' },
{ id: 'target', paths: P.target, label: 'Target', aliases: ['goal', 'aim', 'focus'], category: 'status' },
{ id: 'pulse', paths: P.activity, label: 'Pulse', aliases: ['activity', 'live', 'heartbeat', 'metrics'], category: 'status' },
{ id: 'trend', paths: P.trendingUp, label: 'Trending', aliases: ['rising', 'up', 'graph'], category: 'status' },
{ id: 'battery', paths: P.batteryFull, label: 'Battery', aliases: ['power', 'charge', 'cell'], category: 'status' },
// Office
{ id: 'calendar', paths: P.calendar, label: 'Calendar', aliases: ['date', 'schedule', 'event'], category: 'office' },
{ id: 'mail', paths: P.mail, label: 'Mail', aliases: ['email', 'envelope', 'message'], category: 'office' },
{ id: 'coffee', paths: P.coffee, label: 'Coffee', aliases: ['drink', 'cafe', 'break'], category: 'office' },
{ id: 'briefcase', paths: P.briefcase, label: 'Briefcase', aliases: ['work', 'business', 'job'], category: 'office' },
{ id: 'doc', paths: P.fileText, label: 'Document', aliases: ['file', 'paper', 'text'], category: 'office' },
{ id: 'checklist', paths: P.clipboardList, label: 'Checklist', aliases: ['list', 'tasks', 'todo'], category: 'office' },
{ id: 'hashtag', paths: P.hash, label: 'Hashtag', aliases: ['tag', 'number', 'channel'], category: 'office' },
{ id: 'clock', paths: P.clock, label: 'Clock', aliases: ['time', 'hour', 'minute'], category: 'office' },
];
const _byId: Record<string, DeviceIconDef> = Object.fromEntries(
+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);
@@ -23,6 +23,7 @@ export const flaskConical = '<path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0
export const pencil = '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>';
export const play = '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>';
export const square = '<rect width="18" height="18" x="3" y="3" rx="2"/>';
export const hexagon = '<path d="M21 16.5c0 .38-.21.71-.53.88l-7.9 4.44a1.13 1.13 0 0 1-1.14 0l-7.9-4.44A1 1 0 0 1 3 16.5v-9c0-.38.21-.71.53-.88l7.9-4.44a1.13 1.13 0 0 1 1.14 0l7.9 4.44c.32.17.53.5.53.88z"/>';
export const circle = '<circle cx="12" cy="12" r="9"/>';
export const pause = '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>';
export const settings = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>';
@@ -143,3 +144,61 @@ export const easingIn = '<path d="M4 20C13 20 16 18 20 4"/>';
export const easingOut = '<path d="M4 20C8 6 11 4 20 4"/>';
export const easingInOut = '<path d="M4 20C12 20 12 4 20 4"/>';
export const easingSine = '<path d="M4 12C7 12 8 4 12 4S17 12 20 12"/>';
// ─────────────────────────────────────────────────────────────
// Card-icon picker expansion — additional Lucide icons for the
// custom card-icon selector. These are surfaced through
// device-icons.ts but don't have type-resolution roles.
// ─────────────────────────────────────────────────────────────
// Weather
export const cloudRain = '<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="M16 14v6"/><path d="M8 14v6"/><path d="M12 16v6"/>';
export const cloudSnow = '<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="M8 15h.01"/><path d="M8 19h.01"/><path d="M12 17h.01"/><path d="M12 21h.01"/><path d="M16 15h.01"/><path d="M16 19h.01"/>';
export const cloudLightning = '<path d="M6 16.326A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 .5 8.973"/><path d="m13 12-3 5h4l-3 5"/>';
export const cloudFog = '<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="M16 17H7"/><path d="M17 21H9"/>';
export const wind = '<path d="M12.8 19.6A2 2 0 1 0 14 16H2"/><path d="M17.5 8a2.5 2.5 0 1 1 2 4H2"/><path d="M9.8 4.4A2 2 0 1 1 11 8H2"/>';
export const snowflake = '<line x1="2" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="22"/><path d="m20 16-4-4 4-4"/><path d="m4 8 4 4-4 4"/><path d="m16 4-4 4-4-4"/><path d="m8 20 4-4 4 4"/>';
export const umbrella = '<path d="M22 12a10.06 10.06 0 0 0-20 0Z"/><path d="M12 12v8a2 2 0 0 0 4 0"/><path d="M12 2v1"/>';
export const sunrise = '<path d="M12 2v8"/><path d="m4.93 10.93 1.41 1.41"/><path d="M2 18h2"/><path d="M20 18h2"/><path d="m19.07 10.93-1.41 1.41"/><path d="M22 22H2"/><path d="m8 6 4-4 4 4"/><path d="M16 18a4 4 0 0 0-8 0"/>';
export const sunset = '<path d="M12 10V2"/><path d="m4.93 10.93 1.41 1.41"/><path d="M2 18h2"/><path d="M20 18h2"/><path d="m19.07 10.93-1.41 1.41"/><path d="M22 22H2"/><path d="m16 6-4 4-4-4"/><path d="M16 18a4 4 0 0 0-8 0"/>';
// Nature
export const treeDeciduous = '<path d="M8 19a4 4 0 0 1-2.24-7.32A3.5 3.5 0 0 1 9 6.03V6a3 3 0 1 1 6 0v.04a3.5 3.5 0 0 1 3.24 5.65A4 4 0 0 1 16 19Z"/><path d="M12 19v3"/>';
export const flower2 = '<circle cx="12" cy="8" r="3"/><path d="M12 11v11"/><path d="M5 8a7 7 0 0 1 14 0"/><path d="M12 22c4.2 0 7-1.667 7-5-4.2 0-7 1.667-7 5Z"/><path d="M12 22c-4.2 0-7-1.667-7-5 4.2 0 7 1.667 7 5Z"/>';
export const mountain = '<path d="m8 3 4 8 5-5 5 15H2L8 3z"/>';
export const waves = '<path d="M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/><path d="M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/><path d="M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/>';
export const sprout = '<path d="M7 20h10"/><path d="M10 20c5.5-2.5.8-6.4 3-10"/><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"/><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"/>';
export const trees = '<path d="M10 10v.2A3 3 0 0 1 8.9 16H5a3 3 0 0 1-1-5.8V10a3 3 0 0 1 6 0Z"/><path d="M7 16v6"/><path d="M13 19h6"/><path d="M14 22V13"/><path d="M18 13a4 4 0 0 0 0-8 6 6 0 0 0-9.5-4"/>';
// Rooms (extended)
export const chefHat = '<path d="M17 21a1 1 0 0 0 1-1v-5.35c0-.457.316-.844.727-1.041a4 4 0 0 0-2.134-7.589 5 5 0 0 0-9.186 0 4 4 0 0 0-2.134 7.588c.411.198.727.585.727 1.041V20a1 1 0 0 0 1 1Z"/><path d="M6 17h12"/>';
export const bath = '<path d="M9 6 6.5 3.5a1.5 1.5 0 0 0-1-.5C4.683 3 4 3.683 4 4.5V17a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><line x1="10" x2="8" y1="5" y2="7"/><line x1="2" x2="22" y1="12" y2="12"/><line x1="7" x2="7" y1="19" y2="21"/><line x1="17" x2="17" y1="19" y2="21"/>';
export const warehouse = '<path d="M22 8.35V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8.35A2 2 0 0 1 3.26 6.5l8-3.2a2 2 0 0 1 1.48 0l8 3.2A2 2 0 0 1 22 8.35Z"/><path d="M6 18h12"/><path d="M6 14h12"/><path d="M6 18v-8h12v8"/>';
// Office
export const calendar = '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>';
export const mail = '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>';
export const coffee = '<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>';
export const briefcase = '<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>';
// Media (extended)
export const video = '<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"/><rect x="2" y="6" width="14" height="12" rx="2"/>';
export const disc = '<circle cx="12" cy="12" r="10"/><path d="M6 12c0-1.7.7-3.2 1.8-4.2"/><circle cx="12" cy="12" r="2"/><path d="M18 12c0 1.7-.7 3.2-1.8 4.2"/>';
export const projector = '<path d="M5 7 3 5"/><path d="M9 6V3"/><path d="m13 7 2-2"/><circle cx="9" cy="13" r="3"/><path d="M11.83 12H20a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h.17"/><path d="M16 16h2"/>';
// Hardware (extended)
export const laptop = '<rect width="18" height="12" x="3" y="4" rx="2" ry="2"/><line x1="2" x2="22" y1="20" y2="20"/>';
export const server = '<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>';
export const router = '<rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6.01 18H6"/><path d="M10.01 18H10"/><path d="M15 10v4"/><path d="M17.84 7.17a4 4 0 0 0-5.66 0"/><path d="M20.66 4.34a8 8 0 0 0-11.31 0"/>';
export const webcam = '<circle cx="12" cy="10" r="8"/><circle cx="12" cy="10" r="3"/><path d="M7 22h10"/><path d="M12 22v-4"/>';
export const bot = '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>';
export const watch = '<circle cx="12" cy="12" r="6"/><polyline points="12 10 12 12 13 13"/><path d="m16.13 7.66-.81-4.05a2 2 0 0 0-2-1.61h-2.68a2 2 0 0 0-2 1.61l-.78 4.05"/><path d="m7.88 16.36.8 4a2 2 0 0 0 2 1.61h2.72a2 2 0 0 0 2-1.61l.81-4.05"/>';
export const memoryStick = '<path d="M6 19v-3"/><path d="M10 19v-3"/><path d="M14 19v-3"/><path d="M18 19v-3"/><path d="M8 11V9"/><path d="M16 11V9"/><path d="M12 11V9"/><path d="M2 15h20"/><path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z"/>';
// Lighting (extended)
export const lampCeiling = '<path d="M12 2v5"/><path d="M6 7h12l-2 5H8z"/><path d="M9.17 12a3 3 0 1 0 5.66 0"/>';
export const lampDesk = '<path d="m18 1-3 9"/><path d="m14 5 9 3"/><path d="M16 6 6 16l4 4 10-10"/><path d="M3 22v-2c0-1.1.9-2 2-2h4a2 2 0 0 1 2 2v2H3Z"/>';
export const lampWallUp = '<path d="M11 4h6l3 7H8z"/><path d="M14 11v5a2 2 0 0 1-2 2H8"/><path d="M4 15v-3a6 6 0 0 1 6-6"/>';
export const flashlight = '<path d="M18 6c0 2-2 2-2 4v10a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V10c0-2-2-2-2-4V2h12z"/><line x1="6" x2="18" y1="6" y2="6"/><line x1="12" x2="12" y1="12" y2="12"/>';
export const lightbulbOff = '<path d="M16.8 11.2c.8-.9 1.2-2 1.2-3.2a6 6 0 0 0-9.3-5"/><path d="m2 2 20 20"/><path d="M6.3 6.3a4.67 4.67 0 0 0 1.2 5.2c.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>';
export const candle = '<path d="M3 14v6a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-6"/><path d="M7 14V8a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v6"/><path d="M12 6V4"/><path d="M12 2a1 1 0 0 1 1 1c0 1-1 1.5-1 2.5 0-1-1-1.5-1-2.5a1 1 0 0 1 1-1z"/>';
+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);
});
}
+6 -3
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,13 +42,16 @@ 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 = {
wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb),
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),
dmx: _svg(P.radio), mock: _svg(P.wrench),
espnow: _svg(P.radio), hue: _svg(P.lightbulb), usbhid: _svg(P.usb),
dmx: _svg(P.radio), ddp: _svg(P.send), opc: _svg(P.send), mock: _svg(P.wrench),
espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb), wiz: _svg(P.lightbulb), lifx: _svg(P.lightbulb), govee: _svg(P.lightbulb),
nanoleaf: _svg(P.hexagon),
usbhid: _svg(P.usb),
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
ble: _svg(P.bluetooth),
group: _svg(P.layers),
@@ -0,0 +1,228 @@
/**
* Shared helpers for "light target" editors (HA Light + Z2M Light).
*
* Both editors expose the same five tunable knobs (brightness, update_rate,
* transition, color_tolerance, min_brightness_threshold), the same unified
* Color Source picker (CSS sources + colour-returning value sources), and
* the same IconSelect-driven stop_action picker. This module hosts the
* boilerplate for those three concerns so the per-protocol editor files
* stay focused on what genuinely differs (URLs, mapping row chrome,
* binding-target picker, card chips).
*/
import { BindableScalarWidget } from './bindable-scalar.ts';
import { IconSelect, type IconSelectItem } from './icon-select.ts';
import { getColorStripIcon, getValueSourceIcon } from './icons.ts';
// ──────────────────────────────────────────────────────────────────────
// Bindable scalar widget bundle
// ──────────────────────────────────────────────────────────────────────
interface WidgetConfig {
min: number;
max: number;
step: number;
default: number;
format: (v: number) => string;
}
const DEFAULT_WIDGET_CONFIG: Record<WidgetKey, WidgetConfig> = {
brightness: { min: 0, max: 1, step: 0.05, default: 1.0, format: (v) => v.toFixed(2) },
updateRate: { min: 0.5, max: 5.0, step: 0.1, default: 2.0, format: (v) => v.toFixed(1) },
transition: { min: 0.0, max: 10, step: 0.1, default: 0.5, format: (v) => v.toFixed(1) },
colorTolerance: { min: 0, max: 50, step: 1, default: 5, format: (v) => String(Math.round(v)) },
minBrightnessThreshold: { min: 0, max: 254, step: 1, default: 0, format: (v) => String(Math.round(v)) },
};
export type WidgetKey =
| 'brightness'
| 'updateRate'
| 'transition'
| 'colorTolerance'
| 'minBrightnessThreshold';
/** Maps a WidgetKey to its `id`/`container` slug. */
const WIDGET_SLUG: Record<WidgetKey, string> = {
brightness: 'brightness',
updateRate: 'update-rate',
transition: 'transition',
colorTolerance: 'color-tolerance',
minBrightnessThreshold: 'min-brightness-threshold',
};
export interface LightTargetWidgetsConfig {
/** Prefix for both the BindableScalarWidget `idPrefix` and the container
* element id (e.g. `'ha-light-editor'` container `'ha-light-editor-brightness-container'`). */
idPrefix: string;
/** Value-source list provider for the binding mode of every widget. */
valueSources: () => Array<{ id: string; name: string; source_type: string }>;
/** Per-widget overrides (e.g. wider update_rate range for Z2M). Each
* field is `Partial<WidgetConfig>` and merges over the defaults. */
overrides?: Partial<Record<WidgetKey, Partial<WidgetConfig>>>;
}
/**
* Lazy bundle of the five BindableScalarWidgets every light-target editor
* uses. Widgets are created on first `ensure(key)` and cached; `destroyAll()`
* tears them down and resets the cache so the editor can be re-opened cleanly.
*/
export class LightTargetWidgets {
private _widgets: Partial<Record<WidgetKey, BindableScalarWidget>> = {};
constructor(private opts: LightTargetWidgetsConfig) {}
/** Lazy-create (or return cached) widget for the given key. */
ensure(key: WidgetKey): BindableScalarWidget {
let w = this._widgets[key];
if (w) return w;
const cfg = { ...DEFAULT_WIDGET_CONFIG[key], ...(this.opts.overrides?.[key] || {}) };
const slug = WIDGET_SLUG[key];
const container = document.getElementById(`${this.opts.idPrefix}-${slug}-container`);
if (!container) {
throw new Error(`LightTargetWidgets: container not found for key '${key}' (expected #${this.opts.idPrefix}-${slug}-container)`);
}
w = new BindableScalarWidget({
container,
idPrefix: `${this.opts.idPrefix}-${slug}`,
min: cfg.min,
max: cfg.max,
step: cfg.step,
default: cfg.default,
format: cfg.format,
valueSources: this.opts.valueSources,
});
this._widgets[key] = w;
return w;
}
/** Apply target-response values to all five widgets, lazy-creating each.
* Missing fields fall back to the per-key default. */
applyValues(data: {
brightness?: any;
update_rate?: any;
transition?: any;
color_tolerance?: any;
min_brightness_threshold?: any;
}): void {
this.ensure('brightness').setValue(data.brightness ?? this._defaultFor('brightness'));
this.ensure('updateRate').setValue(data.update_rate ?? this._defaultFor('updateRate'));
this.ensure('transition').setValue(data.transition ?? this._defaultFor('transition'));
this.ensure('colorTolerance').setValue(data.color_tolerance ?? this._defaultFor('colorTolerance'));
this.ensure('minBrightnessThreshold').setValue(data.min_brightness_threshold ?? this._defaultFor('minBrightnessThreshold'));
}
/** Reset all widgets to their (override-aware) default values. */
applyDefaults(): void {
this.ensure('brightness').setValue(this._defaultFor('brightness'));
this.ensure('updateRate').setValue(this._defaultFor('updateRate'));
this.ensure('transition').setValue(this._defaultFor('transition'));
this.ensure('colorTolerance').setValue(this._defaultFor('colorTolerance'));
this.ensure('minBrightnessThreshold').setValue(this._defaultFor('minBrightnessThreshold'));
}
/** Read the current value (BindableFloat-shaped) for a widget. Auto-creates if not yet ensured. */
getValue(key: WidgetKey) {
return this.ensure(key).getValue();
}
destroyAll(): void {
for (const key of Object.keys(this._widgets) as WidgetKey[]) {
this._widgets[key]?.destroy();
}
this._widgets = {};
}
private _defaultFor(key: WidgetKey): number {
return this.opts.overrides?.[key]?.default ?? DEFAULT_WIDGET_CONFIG[key].default;
}
}
// ──────────────────────────────────────────────────────────────────────
// Unified Color Source picker (CSS + colour value sources)
// ──────────────────────────────────────────────────────────────────────
export interface ColorSourcePickerLabels {
/** Header for the CSS sources section (e.g. "Color strip"). */
cssHeader: string;
/** Header for the colour value sources section (e.g. "Color value source"). */
colorVsHeader: string;
}
/**
* Build the dual-section item list for the unified Color Source picker.
* Items use the `css:<id>` / `cvs:<id>` value encoding that both editors
* decode in their `onChange` handlers.
*/
export function buildColorSourcePickerItems(
cssSources: Array<{ id: string; name: string; source_type: string }>,
colorValueSources: Array<{ id: string; name: string; source_type: string }>,
labels: ColorSourcePickerLabels,
): Array<{ value: string; label: string; icon: string; desc?: string; header?: boolean }> {
const items: Array<{ value: string; label: string; icon: string; desc?: string; header?: boolean }> = [];
if (cssSources.length > 0) {
items.push({ value: '', label: labels.cssHeader, icon: '', header: true });
for (const s of cssSources) {
items.push({
value: `css:${s.id}`,
label: s.name,
icon: getColorStripIcon(s.source_type),
desc: s.source_type,
});
}
}
if (colorValueSources.length > 0) {
items.push({ value: '', label: labels.colorVsHeader, icon: '', header: true });
for (const s of colorValueSources) {
items.push({
value: `cvs:${s.id}`,
label: s.name,
icon: getValueSourceIcon(s.source_type),
desc: s.source_type,
});
}
}
return items;
}
/** Decode a unified picker value (`css:abc` / `cvs:abc` / `''`) into kind+id. */
export function decodeColorSourceValue(raw: string): { kind: 'css' | 'color_vs'; id: string } {
const kind: 'css' | 'color_vs' = raw.startsWith('cvs:') ? 'color_vs' : 'css';
const id = raw.includes(':') ? raw.slice(raw.indexOf(':') + 1) : '';
return { kind, id };
}
/** Encode a kind+id pair into the unified picker's option-value string. */
export function encodeColorSourceValue(kind: 'css' | 'color_vs', id: string): string {
if (!id) return '';
return `${kind === 'color_vs' ? 'cvs' : 'css'}:${id}`;
}
// ──────────────────────────────────────────────────────────────────────
// Stop-action IconSelect
// ──────────────────────────────────────────────────────────────────────
/**
* Wire (or refresh) an IconSelect for a stop-action `<select>`.
*
* Pass the existing `IconSelect | null` ref; the helper either calls
* `updateItems()` on it or constructs a new one. The (new or existing)
* instance is returned so the caller can keep the singleton ref alive.
*/
export function ensureStopActionIconSelect(
selectId: string,
items: IconSelectItem[],
columns: number,
existing: IconSelect | null,
): IconSelect | null {
const sel = document.getElementById(selectId) as HTMLSelectElement | null;
if (!sel) return existing;
if (existing) {
existing.updateItems(items);
return existing;
}
return new IconSelect({ target: sel, items, columns });
}
@@ -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;
}

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