Compare commits

...

54 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
alexei.dolgolyov 09792a9a05 chore: release v0.6.1
Build Release / create-release (push) Successful in 4s
Build Android APK / build-android (push) Failing after 9s
Build Release / build-linux (push) Successful in 2m13s
Build Release / build-docker (push) Successful in 3m9s
Build Release / build-windows (push) Successful in 4m6s
2026-05-10 23:57:47 +03:00
alexei.dolgolyov 75ca487be1 feat(ui): per-surface card presentation modes (C/M/D/R)
Adds a comfortable/compact/dense/row toggle to every card grid in the
app. Each surface (LED devices, targets, automations, scenes, sources,
streams, dashboard subsections, etc.) remembers its mode independently.

Persistence mirrors dashboard-layout: localStorage cache for first paint,
debounced PUT to /api/v1/preferences/card-modes (new endpoint) for
cross-browser sync. Surface registry is open — any non-empty key
accepted server-side; modes validated against {comfortable, compact,
dense, row}.

CSS is token-driven: grid min-width and gap come from --card-grid-min /
--card-grid-gap / --card-grid-min-narrow / --card-grid-gap-narrow /
--templates-grid-min / --templates-grid-gap defined on :root, overridden
per [data-card-mode]. Dense/row also hide .mod-leds, collapse secondary
button labels, and tighten .mod-metrics; row collapses the grid to one
full-width column. Coexists with the existing per-section [data-density]
on the dashboard tab — different attribute, additive concern.

Toggle UI auto-mounts into every CardSection header (18+ surfaces) plus
the six dashboard subsections via post-render mount; teardown tracking
keeps the listener Set bounded across re-renders.

i18n: card_mode.{tooltip,comfortable,compact,dense,row} in en/ru/zh.
Tests: 9 new cases in tests/test_preferences_card_modes_api.py covering
defaults, round-trip, validation, open-registry keys, row mode, delete.
2026-05-10 23:49:14 +03:00
alexei.dolgolyov e65dcb41f4 chore: clean up cfg abbreviation and stale TODO link
Rename `cfg` parameter/local in resolve_mqtt_password to `config`
for PEP 8 compliance. Drop the broken reference to the long-removed
docs/plans/device-typed-configs.md from TODO.md.
2026-05-10 23:19:15 +03:00
alexei.dolgolyov 6a07a6b1a2 fix(shutdown): apply target stop actions before tearing down HA/MQTT
Reorder the lifespan shutdown so processor_manager.stop_all() runs before
ha_manager.shutdown(), mqtt_manager.shutdown(), and mqtt_service.stop().
HA-light targets check `_ha_runtime.is_connected` before applying their
`stop_action` (turn_off / restore) and silently skip when HA is already
disconnected; MQTT-output devices need the broker connection alive to
send restore frames. The previous order tore those down first, turning
"stop_targets" into a no-op for those targets — most visible when
closing via the tray Shutdown button.

Also moves automation_engine.stop(), discovery_watcher.stop(), and the
OS notification listener stop ahead of processor stop so they can no
longer fire events into a shutting-down processor manager. Independent
services (weather, update checker, auto-backup) now run last, where
their order does not matter.

Bonus: if the daemon-thread join times out (10 s) and the rest of
shutdown is cut short, the user-visible part — targets stopping — has
already run.
2026-05-10 22:39:18 +03:00
alexei.dolgolyov 0f5850ef80 feat(ui): customisable card icon for all entity types
Extends the icon-plate work from devices and output targets to every
remaining card type — 18 new entities, 20 in total. Users can now pick
a curated icon (with optional colour override) for any card on any tab,
and the picker reuses the same modal, recent-strip, search, and
category tabs introduced for the device picker.

Foundation:
- icon-picker.ts — replace the hardcoded 2-entry adapter record with a
  Map<EntityType, EntityTypeAdapter> and expose
  registerIconEntityType() + makeSimpleIconAdapter() so each feature
  module owns its own adapter (~6 lines per type).
- bodyExtras hook on adapters, keyed off id, lets discriminated routes
  (output-targets target_type, picture-sources stream_type, audio /
  value / color-strip-sources source_type) accept icon-only PUTs.
- core/card-icon.ts — new makeCardIconFields(type, id, entity) helper
  spreads iconHtml / iconColor / iconAttrs into a mod-card head in one
  line.
- _onDocumentClick now accepts any registered type instead of a
  hardcoded device/target check.

Backend (purely additive — no migrations needed thanks to JSON-blob
storage):
- 18 dataclasses gained icon: str = "" + icon_color: str = "" with
  emit-when-truthy serialisation and "" defaults on load.
- All matching Create / Update / Response Pydantic schemas gained the
  fields with the standard Optional[str] + max_length=64/32 +
  description set.
- All routes' response builders use
  getattr(entity, "icon", "") or "" so existing rows render unchanged.
- ValueSource and CSS handle icon/icon_color on the base class so all
  source-type subclasses inherit them automatically.

Frontend wiring (12 modules):
- streams.ts — picture sources, capture templates, PP templates,
  CSPT, audio sources, audio templates, gradients (built-in
  gradients keep no plate).
- automations, scene-presets, sync-clocks, weather-sources,
  value-sources, mqtt-sources, home-assistant-sources,
  game-integration, audio-processing-templates, assets,
  color-strips/cards.
- pattern-templates skipped — uses the legacy wrapCard({content,
  actions}) string API, separate migration.

Dashboard cards now also display the chosen icon:
- Targets already had it (with device inheritance for LED targets).
- Sync clocks, automations, and scene presets gained the same plate
  via a shared _dashboardIconPlate helper that mirrors the mod-card
  layout (mod-head--with-icon class flips on when present).

i18n: 20 new device.icon.entity.<type> labels in en/ru/zh.

Verification:
- ruff check src/ tests/ — clean.
- npx tsc --noEmit — clean.
- npm run build — 2.6 MB bundle.
- pytest tests/ --no-cov — 949 passed (no regressions).

Pending: manual smoke test on each card type — open picker, save, and
confirm the channel-color preview matches the live card.
2026-05-09 16:19:20 +03:00
alexei.dolgolyov a79f4bf73c feat(ha-light): broadcast a single Color Value Source to all entities
HALightOutputTarget gains a `source_kind` field with two modes:
- `css` (existing): per-mapping LED segments averaged from a ColorStripSource.
- `color_vs` (new): one colour from a colour-returning ValueSource pushed to
  every mapped entity (mapping LED ranges are ignored in this mode).

Backend wiring:
- Schema/route: add `source_kind` + `color_value_source_id` to create/update/
  response payloads, with VS existence + return_type=color validation.
- Storage: persist new fields, with defensive `or ""` coalesce so legacy rows
  written via resolve_ref with None survive the str-typed response schema.
- Processor: ha_light_target_processor reworked to drive both source kinds
  (incl. update_target_settings hot-swap of source mode). New unit tests in
  tests/core/test_ha_light_target_processor.py and extended store tests.

Frontend:
- ha-light editor modal: collapsed Color Strip + Color VS into one
  "Color Source" picker with grouped headers; mappings list shows a
  mode-aware hint when broadcasting a single colour.
- EntityPalette: support non-selectable header rows (with keyboard / filter
  handling) for grouped source pickers.

Bundled UI polish (icon inheritance + cleanup):
- Custom card icons now flow into more surfaces: command palette, dashboard
  target cards, scene-preset target picker, calibration test-device picker,
  and the LED-target device picker. LED targets inherit their device's icon
  when none is set on the target itself.
- Empty mod-card icon plates render as a dashed "+" placeholder when an
  icon-picker hook is wired, so the action stays discoverable.
- Icon picker: distinct "HA light target" eyebrow label and supports
  HA-light cards (data-ha-target-id) for channel-colour resolution.
- Update banner: "View release" now opens the in-app Update settings tab
  instead of an external link; uses the sparkles icon.
- Color-strip delete: cleaner toast on 409 conflict.
2026-05-04 14:27:22 +03:00
alexei.dolgolyov ced72fc864 feat(targets): customisable card icon + HA-light stop action
Extends the icon-plate work from the device cards to LED and HA-light
output targets, and adds finalization behaviour for HA-light targets.

Targets:
- Add `icon` and `icon_color` fields to OutputTarget (LED + HA-light).
- LED target cards inherit the icon from their referenced device when
  no override is set; the icon picker shows an "inherited" indicator.
- Promote the device link from the meta line to a chip with the
  device's custom icon, leaving the head row free for the icon plate.

HA-light:
- New `stop_action` field with three modes: `none` / `turn_off` /
  `restore`. Processor snapshots mapped-entity states at start and
  applies the chosen action on stop (rgb / hs / color_temp / brightness
  restored where present).
- Editor modal exposes the choice via an IconSelect of three modes.

Adjacent fixes:
- Fader slider hit-zone now overlays the visible track exactly,
  regardless of label/value column widths.
- Dashboard customise drag-drop indicator splits into before/after
  rather than highlighting the whole row.
- Picture-source EntitySelect resyncs its visible value on load.
2026-05-04 00:43:55 +03:00
alexei.dolgolyov 49ddabbc36 feat(ui): customisable card icon plate for devices
A user-chosen icon ("mouse", "motherboard", "keyboard"…) renders as a
44x44 instrument-panel face plate at the leading edge of .mod-head on
device cards. Optional per-card; null hides the plate and reverts to
the existing badge-only head.

- Storage/schema: new icon, icon_color fields on Device + DeviceUpdate /
  DeviceResponse. SQLite stores entities as a JSON blob, so no migration
  is needed; from_dict defaults handle existing rows.
- Curated 47-icon library across six categories (Hardware / Lighting /
  Rooms / Media / Signal / Ambience), reusing the existing Lucide path
  module; adds circuit-board, bed, armchair, leaf paths.
- mod-card.ts: ModHeadOpts gains iconHtml / iconColor / iconAttrs;
  ModMenuItemOpts gains optional dataAttrs. The plate is rendered when
  iconHtml is supplied; otherwise no layout change.
- Picker modal (icon-picker.html + features/icon-picker.ts): live
  preview, search, six category tabs, recent strip, channel-color
  override toggle. Wired through document-level click delegation on
  [data-icon-picker-trigger="<deviceId>"] — no window globals, no
  inline onclick string. Sets the precedent for migrating other card
  actions off window in a follow-up.
- en/ru/zh locales for picker UI + categories.

Includes a docs/ mockup that's the source-of-truth for the design.
2026-05-03 15:08:17 +03:00
alexei.dolgolyov a026f0b349 ci(android): fail-fast on missing release keystore before SDK setup
Move the keystore guard from after the Decode step (step 9) to right
after Resolve build label (step 3). A release tag pushed without
ANDROID_KEYSTORE_BASE64 configured now fails in seconds instead of
after JDK + Python + Android SDK + NDK install (~3-5 min of wasted
runner time). Switched the condition from steps.keystore.outputs.present
to env.ANDROID_KEYSTORE_BASE64 since the env var is set at job level
and the keystore decode step has not yet run at the new position.
2026-05-01 19:18:46 +03:00
304 changed files with 34002 additions and 2919 deletions
+11 -9
View File
@@ -54,6 +54,17 @@ jobs:
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
echo "Build label: $LABEL (release=$IS_RELEASE)"
- name: Guard release tag against missing keystore
# Release tags MUST produce a release-signed APK, otherwise existing
# installs can't upgrade (signature mismatch). Fail loudly instead
# of silently falling back to the debug signing config.
# Runs before JDK/Python/SDK/NDK setup so a misconfigured release
# tag fails in seconds instead of after several minutes of setup.
if: ${{ steps.label.outputs.is_release == 'true' && env.ANDROID_KEYSTORE_BASE64 == '' }}
run: |
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
exit 1
- name: Setup JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
@@ -122,15 +133,6 @@ jobs:
echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT"
echo "present=true" >> "$GITHUB_OUTPUT"
- name: Guard release tag against missing keystore
# Release tags MUST produce a release-signed APK, otherwise existing
# installs can't upgrade (signature mismatch). Fail loudly instead
# of silently falling back to the debug signing config.
if: ${{ steps.label.outputs.is_release == 'true' && steps.keystore.outputs.present != 'true' }}
run: |
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
exit 1
- name: Build APK
working-directory: android
env:
+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.
+20 -40
View File
@@ -1,42 +1,28 @@
## v0.6.0 (2026-05-01)
This release adds **device-event notifications** (snack + Web Notifications), a **daylight/timezone-aware streaming pipeline** with a new camera engine, a **redesigned Targets surface** built on the dashboard's mod-card system, a **tighter LED hot path** with allocation-free per-frame work, and a **revamped Release Notes overlay** with clickable asset downloads. Plus a wide pass of modal, toolbar, and settings polish across the WebUI.
## v0.6.1 (2026-05-10)
### Features
- **Device event notifications** — configurable per-event channel matrix (none / snack / OS / both) for target online/offline, new WLED/serial discovery, and devices going missing. Backed by a long-running mDNS browser + 10 s serial poller, a startup-grace / flap-debounce / bulk-coalesce pipeline, and a new Notifications tab in Settings (en/ru/zh). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
- **Daylight + timezone streaming** — new `daylight_settings` module and `daylight-tz` frontend helper expand the daylight stream's behavior; capture path additions land alongside a new **camera engine** test suite. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
- **Targets cards migrated to the mod-card system** — LED targets and HA Light targets now share the dashboard's instrument-readout vocabulary (mod-head / mod-leds / mod-metrics / mod-foot, kebab menu, badges, chips, patch indicator). LED preview, FPS sparkline, and pipeline metrics preserved via an `extraHtml` escape hatch. ([233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463))
- **Target pipeline as a compact strip + chip row** — drops the legacy "Pipeline details" collapsible block; an always-visible 4 px segmented timing bar (extract / map / smooth / send for video, read / fft / render / send for audio) sits above an inline chip row showing total ms / frames / keepalives, animating smoothly between samples. ([51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2))
- **Targets metrics aligned with the dashboard** — FPS sparkline now lives inside the FPS cell, Uptime gets a clock icon, Errors gets ok/warning by count, FPS readout adopts the dashboard `current/target avg N.N` shape, and the grid sizes so values like `1m 43s` no longer truncate at typical desktop widths. ([9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2))
- **Release Notes overlay v2** — new masthead with display-font title, tag/published/pre-release chip strip, and close/external actions; markdown body fuzzy-matches `<code>` filenames to release assets and renders clickable download links with per-asset descriptions (Windows installer/portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android apk/aab, iOS ipa). Checksum/signature side-files are hidden. ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
- **Tutorials expansion** — sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks let tours open/close the dashboard customize panel and resolve targets behind sub-tabs; new steps for integrations, dashboard customize panel (presets / global / sections / perf cells), targets, scenes, and sync-clocks (en/ru/zh). ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
- **Cards / settings / modal / toolbar polish** — reworked mod-card colors, sections, channel-stripe styling, hairline borders, and signal-flow animation on running cards; multiselect bulk toolbar gets explicit Select-all / Deselect-all icons with luxury-gradient toolbar styling; Settings tabs are now icon-only (no overflow at any locale); modal exit animation gains symmetric fadeOut + slideDown keyframes with reduced-motion support; locale picker collapses to EN / RU / ZH; snack toast adopts a glass background with per-type accent. ([a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b))
- **Suppress browser auto-open on Windows login** — when "Start with Windows" is enabled, the autostart shortcut now passes `--autostart` so the WebUI tab no longer pops on every login. Manual launches and the installer's "Launch LedGrab" finish-page action are unchanged. ([de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44))
- **Simpler segment payloads** — `SegmentPayload.start` defaults to 0 and `length` defaults to "the rest of the strip from start". A single segment with only `mode` + `color` now fills the entire strip — no more `length: 9999` magic value clients had to pass. ([1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5))
- About panel now houses the author + contact details that previously lived in a global app footer, freeing up vertical space across every page (en/ru/zh `donation.about_author` key added). ([816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d))
### Performance
- **LED hot path is allocation-free per-frame**: Adalight gets a dedicated single-worker tx executor, pre-allocated wire buffer, uint8 scratch, and a precomputed header struct; DDP gets a pre-built `struct.Struct` and memoryview emit path; calibration precomputes Phase 3 skip-LED resampling so per-frame work is now `np.take` + in-place blend; the WLED target processor gets a matching tightening. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
- Per-surface card presentation modes (C/M/D/R) for the UI ([75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487))
- Customisable card icon for all entity types ([0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e))
- HA-Light: broadcast a single Color Value Source to all entities ([a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf))
- Targets: customisable card icon plus HA-light stop action ([ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc))
- Customisable card icon plate for devices ([49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb))
### Bug Fixes
- **Audio-source modal preserves device on refresh** — refresh button moved into the label row (no more overflow past the Source panel edge); selection is restored by matching on `(index, loopback)` first with a trimmed-name fallback for OS-side reindexing; the EntitySelect trigger now syncs so the visible label matches the underlying `<select>` in edit mode. ([0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4))
- **PWA meta tag** — add the standard `mobile-web-app-capable` tag while keeping the Apple variant for iOS Safari, since Chrome deprecated `apple-mobile-web-app-capable`. ([8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3))
- Shutdown: apply target stop actions before tearing down HA/MQTT so devices end up in their configured state ([6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b))
---
### Development / Internal
#### CI/Build
- Add `workflow_dispatch` and skip lint/test on release commits (release.yml already runs in parallel; manual dispatch covers re-runs on demand). ([033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6))
#### Tests
- New `test_camera_engine` suite covers the new capture path. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
- Adalight + DDP tests cover header format, buffer reuse, non-contiguous input, brightness scaling, RGB/RGBW packets, sequence/PUSH semantics, and multi-packet fragmentation. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
- 13 new tests for the device-event notifications backend (full suite still 899 passing). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
- `conftest` pre-creates the test DB so `main.py`'s legacy-data migration no longer shovels the user's production DB into the test temp dir; `test_preferences_notifications` wipes its own setting at the start of the defaults test (was relying on isolation it never enforced). ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
- Android: fail-fast on missing release keystore before SDK setup ([a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b))
#### Tooling
- `.mcp.json` checked in with code-review-graph MCP server config so the graph tools are available out of the box. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
#### Chores
- Clean up `cfg` abbreviation and stale TODO link ([e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4))
---
@@ -45,19 +31,13 @@ This release adds **device-event notifications** (snack + Web Notifications), a
| Hash | Message | Author |
|------|---------|--------|
| [0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4) | fix(ui): audio-source modal — preserve device on refresh, relocate refresh action | alexei.dolgolyov |
| [fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b) | feat: daylight tz, camera engine, value stream + modal/UI polish | alexei.dolgolyov |
| [816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d) | refactor(ui): drop app footer, move author info to About panel | alexei.dolgolyov |
| [797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806) | feat: LED hot-path perf, tutorials expansion, modal markup polish | alexei.dolgolyov |
| [9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534) | feat(ui): release notes overlay v2 + settings/streams/dashboard polish | alexei.dolgolyov |
| [51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2) | feat(ui): redesign target pipeline as compact strip + chip row | alexei.dolgolyov |
| [9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2) | feat(ui): align Targets metric cells with dashboard pattern | alexei.dolgolyov |
| [233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463) | feat(ui): migrate Targets cards to mod-card system | alexei.dolgolyov |
| [de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44) | feat(autostart): suppress browser auto-open on Windows login | alexei.dolgolyov |
| [1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5) | feat(api-input): make SegmentPayload start/length optional | alexei.dolgolyov |
| [a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b) | feat(ui): cards redesign + settings, modal, toolbar polish | alexei.dolgolyov |
| [8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32) | feat(notifications): device event notifications (snack + Web Notifications) | alexei.dolgolyov |
| [8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3) | fix(pwa): add mobile-web-app-capable meta tag | alexei.dolgolyov |
| [033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6) | ci: add workflow_dispatch and skip lint/test on release commits | alexei.dolgolyov |
| [75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487) | feat(ui): per-surface card presentation modes (C/M/D/R) | alexei.dolgolyov |
| [e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4) | chore: clean up cfg abbreviation and stale TODO link | alexei.dolgolyov |
| [6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b) | fix(shutdown): apply target stop actions before tearing down HA/MQTT | alexei.dolgolyov |
| [0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e) | feat(ui): customisable card icon for all entity types | alexei.dolgolyov |
| [a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf) | feat(ha-light): broadcast a single Color Value Source to all entities | alexei.dolgolyov |
| [ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc) | feat(targets): customisable card icon + HA-light stop action | alexei.dolgolyov |
| [49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb) | feat(ui): customisable card icon plate for devices | alexei.dolgolyov |
| [a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b) | ci(android): fail-fast on missing release keystore before SDK setup | alexei.dolgolyov |
</details>
+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.
+542 -2
View File
@@ -1,5 +1,389 @@
# 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)
to all remaining card types. ~17 entity types. Branch: `feat/icons-everywhere`.
### Foundation
- [x] Refactor `icon-picker.ts` — replace hardcoded 2-entry `_adapters`
record with a `Map<EntityType, EntityTypeAdapter>` and expose
`registerIconEntityType()` for feature modules to register their
own. Added `makeSimpleIconAdapter()` helper that reduces a
registration to ~6 lines.
- [x] Generalised `bodyExtras` for discriminated routes (output-targets
`target_type` etc.) — now keyed off id, adapter does its own
lookup.
- [x] `_onDocumentClick` accepts any registered type instead of
hardcoded device/target check.
- [x] Locale entity-type labels added to en/ru/zh for 18 new types
(picture_source, audio_source, weather_source, value_source,
mqtt_source, ha_source, automation, scene_preset, sync_clock,
game_integration, audio_processing_template, pattern_template,
capture_template, pp_template, cspt, audio_template, gradient,
color_strip_source, asset).
### Backend (storage + schemas + routes per entity)
Recipe: add `icon: str = ""` + `icon_color: str = ""` to dataclass,
emit-when-truthy in `to_dict`, default `""` in `from_dict`; add 3
`Optional[str]` Field defs to Create/Response/Update schemas; thread
`getattr(entity, "icon", "") or ""` into the response builder.
SQLite JSON-blob storage means **no migration required**.
- [x] Integrations (6): weather_sources, value_sources, mqtt_source,
home_assistant_source, sync_clocks, game_integration
- [x] Streams (10): picture_source, audio_source, audio_template,
audio_processing_template, pattern_template, postprocessing_template,
color_strip_processing_template, color_strip_source, gradient,
capture_template (`storage/template.py` — was missed by initial pass)
- [x] Other (3): automation, scene_preset, asset
### Frontend (per feature module)
For each card render call:
- Use the new `core/card-icon.ts` helper:
`...makeCardIconFields('<type>', entity.id, entity)` spread into the
mod-card head — computes `iconHtml`/`iconColor`/`iconAttrs` in one go.
- Register the entity type in the feature module via
`registerIconEntityType('<type>', makeSimpleIconAdapter({ … }))`.
Modules wired:
- [x] streams.ts (7 cards: picture, capture, pp, cspt, audio source,
audio template, gradient — built-in gradients skip the plate)
- [x] automations.ts
- [x] scene-presets.ts
- [x] sync-clocks.ts
- [x] weather-sources.ts
- [x] value-sources.ts (bodyExtras propagates `source_type`)
- [x] mqtt-sources.ts
- [x] home-assistant-sources.ts
- [x] game-integration.ts
- [x] audio-processing-templates.ts
- [x] assets.ts
- [x] color-strips/cards.ts (bodyExtras propagates `source_type`)
- [WONTDO] pattern-templates.ts — uses legacy `wrapCard({content, actions})`
string API, not the mod-card system. Migration would be a separate
effort and the cards are tiny (name + rect count) so the value is low.
### Discriminated routes
Adapters provide `bodyExtras` to inject the discriminator field on PUT
so the Pydantic discriminated-union route validators don't reject the
icon-only update:
- output-targets → `target_type` (already wired before)
- color-strip-sources → `source_type`
- audio-sources → `source_type`
- value-sources → `source_type`
- picture-sources → `stream_type`
### Verification
- [x] `cd server && ruff check src/ tests/` clean
- [x] `cd server && npx tsc --noEmit` clean
- [x] `cd server && npm run build` produces 2.6 MB bundle
- [x] `cd server && py -3.13 -m pytest tests/ --no-cov -q` — 949 passed
- [ ] Manual: open picker on each card type, confirm save persists,
confirm channel-color preview matches the live card
## Device Event Notifications
Notify the user when LED devices come online/go offline (configured targets), and when new
@@ -41,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.
@@ -434,9 +818,165 @@ Beyond the `/proc`-based AndroidMetricsProvider that's now in place:
## Refactor: Per-Provider Device Configs
Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses. Full plan: [docs/plans/device-typed-configs.md](docs/plans/device-typed-configs.md).
Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses.
- [x] Phase 1 — `DeviceConfig` hierarchy + `Device.to_config()` (non-breaking, additive only)
- [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).
+1 -1
View File
@@ -40,7 +40,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.6.0"
versionName = "0.6.1"
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
File diff suppressed because it is too large Load Diff
+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"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.6.0"
version = "0.6.1"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+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,
}
+7 -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)",
)
@@ -142,6 +143,8 @@ async def update_asset(
name=body.name,
description=body.description,
tags=body.tags,
icon=body.icon,
icon_color=body.icon_color,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
@@ -36,6 +36,8 @@ def _apt_to_response(t) -> AudioProcessingTemplateResponse:
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
@@ -73,6 +75,8 @@ async def create_audio_processing_template(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("audio_processing_template", "created", template.id)
return _apt_to_response(template)
@@ -129,6 +133,8 @@ async def update_audio_processing_template(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("audio_processing_template", "updated", template_id)
# Hot-update: rebuild filter pipelines for running streams using this template
@@ -46,6 +46,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
device_index=s.device_index,
is_loopback=s.is_loopback,
audio_template_id=s.audio_template_id,
@@ -57,6 +59,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
audio_source_id=s.audio_source_id,
audio_processing_template_id=s.audio_processing_template_id,
),
@@ -75,6 +79,8 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
device_index=getattr(source, "device_index", -1),
is_loopback=getattr(source, "is_loopback", True),
audio_template_id=getattr(source, "audio_template_id", None),
@@ -53,6 +53,8 @@ async def list_audio_templates(
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
for t in templates
]
@@ -81,6 +83,8 @@ async def create_audio_template(
engine_config=data.engine_config,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse(
@@ -92,6 +96,8 @@ async def create_audio_template(
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
icon=getattr(template, "icon", "") or "",
icon_color=getattr(template, "icon_color", "") or "",
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -127,6 +133,8 @@ async def get_audio_template(
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
@@ -150,6 +158,8 @@ async def update_audio_template(
engine_config=data.engine_config,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse(
@@ -161,6 +171,8 @@ async def update_audio_template(
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -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:
@@ -122,6 +128,8 @@ def _automation_to_response(
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
tags=automation.tags,
icon=getattr(automation, "icon", "") or "",
icon_color=getattr(automation, "icon_color", "") or "",
created_at=automation.created_at,
updated_at=automation.updated_at,
)
@@ -191,6 +199,8 @@ async def create_automation(
deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
if automation.enabled:
@@ -285,6 +295,8 @@ async def update_automation(
rules=rules,
deactivation_mode=data.deactivation_mode,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
if data.scene_preset_id is not None:
update_kwargs["scene_preset_id"] = data.scene_preset_id
+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")
@@ -43,6 +43,8 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
@@ -84,6 +86,8 @@ async def create_cspt(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("cspt", "created", template.id)
return _cspt_to_response(template)
@@ -141,6 +145,8 @@ async def update_cspt(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("cspt", "updated", template_id)
return _cspt_to_response(template)
@@ -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,
@@ -65,6 +51,8 @@ def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
)
@@ -92,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,
@@ -130,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,
@@ -151,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(),
@@ -170,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(),
@@ -186,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],
@@ -205,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
+214 -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,
@@ -71,6 +108,8 @@ def _device_to_response(device) -> DeviceResponse:
default_css_processing_template_id=device.default_css_processing_template_id,
group_device_ids=device.group_device_ids,
group_mode=device.group_mode,
icon=getattr(device, "icon", "") or "",
icon_color=getattr(device, "icon_color", "") or "",
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -132,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":
@@ -166,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
@@ -179,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,
@@ -191,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",
@@ -231,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,
@@ -264,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()}
@@ -383,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
@@ -403,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,
@@ -426,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,
@@ -439,19 +636,20 @@ async def update_device(
ble_govee_key=update_data.ble_govee_key,
group_device_ids=update_data.group_device_ids,
group_mode=update_data.group_mode,
icon=update_data.icon,
icon_color=update_data.icon_color,
)
# Sync connection info in processor manager
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)
@@ -158,6 +158,8 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
updated_at=config.updated_at,
description=config.description,
tags=config.tags,
icon=getattr(config, "icon", "") or "",
icon_color=getattr(config, "icon_color", "") or "",
)
@@ -255,6 +257,8 @@ async def create_integration(
event_mappings=mappings,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("game_integration", "created", config.id)
@@ -323,6 +327,8 @@ async def update_integration(
event_mappings=mappings,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("game_integration", "updated", integration_id)
@@ -35,6 +35,8 @@ def _to_response(gradient: Gradient) -> GradientResponse:
tags=gradient.tags,
created_at=gradient.created_at,
updated_at=gradient.updated_at,
icon=getattr(gradient, "icon", "") or "",
icon_color=getattr(gradient, "icon_color", "") or "",
)
@@ -66,6 +68,8 @@ async def create_gradient(
stops=[s.model_dump() for s in data.stops],
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("gradient", "created", gradient.id)
return _to_response(gradient)
@@ -103,6 +107,8 @@ async def update_gradient(
stops=stops,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("gradient", "updated", gradient_id)
return _to_response(gradient)
@@ -55,6 +55,8 @@ def _to_response(
entity_count=len(runtime.get_all_states()) if runtime else 0,
description=source.description,
tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
token=token_field,
@@ -105,6 +107,8 @@ async def create_ha_source(
entity_filters=data.entity_filters,
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))
@@ -158,6 +162,8 @@ async def update_ha_source(
entity_filters=data.entity_filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
@@ -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,
)
+6
View File
@@ -45,6 +45,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
connected=runtime.is_connected if runtime else False,
description=source.description,
tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
)
@@ -90,6 +92,8 @@ async def create_mqtt_source(
base_topic=data.base_topic,
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))
@@ -139,6 +143,8 @@ async def update_mqtt_source(
base_topic=data.base_topic,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found")
+399 -129
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,17 +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
@@ -29,7 +39,13 @@ 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
from ledgrab.storage.base_store import EntityNotFoundError
@@ -54,6 +70,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
protocol=target.protocol,
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,
)
@@ -66,8 +84,11 @@ def _ha_light_target_to_response(
return HALightOutputTargetResponse(
id=target.id,
name=target.name,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
ha_source_id=target.ha_source_id or "",
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
# Defensive coalesce — older records stored via resolve_ref may hold None.
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(),
ha_light_mappings=[
HALightMappingSchema(
@@ -82,34 +103,183 @@ def _ha_light_target_to_response(
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,
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 _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,
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:
"""Ensure the referenced ValueSource exists and returns colour."""
if not color_value_source_id:
raise HTTPException(
status_code=400,
detail="color_value_source_id is required when source_kind='color_vs'",
)
try:
source = value_source_store.get_source(color_value_source_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(
status_code=422,
detail=f"Color value source {color_value_source_id} not found",
)
if source.to_dict().get("return_type") != "color":
raise HTTPException(
status_code=400,
detail=(
f"Value source {color_value_source_id} does not return colour "
"(return_type must be 'color')"
),
)
_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
)
@@ -119,53 +289,70 @@ async def create_target(
target_store: OutputTargetStore = Depends(get_output_target_store),
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")
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", ""),
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),
)
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:
@@ -233,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"]
)
@@ -243,90 +442,161 @@ async def update_target(
target_store: OutputTargetStore = Depends(get_output_target_store),
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
# 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,
ha_source_id=getattr(data, "ha_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),
)
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)
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
),
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 =====
@@ -39,6 +39,8 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
@@ -83,6 +85,8 @@ async def create_pattern_template(
rectangles=rectangles,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("pattern_template", "created", template.id)
return _pat_template_to_response(template)
@@ -139,6 +143,8 @@ async def update_pattern_template(
rectangles=rectangles,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("pattern_template", "updated", template_id)
return _pat_template_to_response(template)
@@ -65,6 +65,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
display_index=s.display_index,
capture_template_id=s.capture_template_id,
target_fps=s.target_fps,
@@ -76,6 +78,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
source_stream_id=s.source_stream_id,
postprocessing_template_id=s.postprocessing_template_id,
),
@@ -86,6 +90,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
image_asset_id=s.image_asset_id,
),
VideoCaptureSource: lambda s: VideoPictureSourceResponse(
@@ -95,6 +101,8 @@ _RESPONSE_MAP = {
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
video_asset_id=s.video_asset_id,
loop=s.loop,
playback_speed=s.playback_speed,
@@ -49,6 +49,8 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
updated_at=t.updated_at,
description=t.description,
tags=t.tags,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
@@ -86,6 +88,8 @@ async def create_pp_template(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("pp_template", "created", template.id)
return _pp_template_to_response(template)
@@ -143,6 +147,8 @@ async def update_pp_template(
filters=filters,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("pp_template", "updated", template_id)
return _pp_template_to_response(template)
@@ -37,6 +37,7 @@ router = APIRouter()
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
_NOTIFICATION_PREFS_KEY = "notification_preferences"
_CARD_MODES_KEY = "card_modes"
class DaylightTimezonePreference(BaseModel):
@@ -163,6 +164,90 @@ async def put_notification_preferences(
return body
# ---------------------------------------------------------------------------
# Card presentation modes (per-surface comfortable/compact/dense)
# ---------------------------------------------------------------------------
_VALID_CARD_MODES = {"comfortable", "compact", "dense", "row"}
@router.get(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def get_card_modes(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, Any]:
"""Read the saved card-mode preferences. Returns an empty object when
nothing has been saved yet the frontend falls back to the default
mode ("compact") for every surface in that case."""
value = db.get_setting(_CARD_MODES_KEY)
return value if value is not None else {}
@router.put(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def put_card_modes(
_: AuthRequired,
body: dict[str, Any] = Body(...),
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Save card-mode preferences. The body must be a JSON object shaped
like ``{"version": 1, "surfaces": {"<surface>": "<mode>", }}``.
The surface registry is intentionally open (any string accepted) so
new card surfaces can adopt the toggle without a server migration.
Invalid mode values are rejected to prevent a bad client from
poisoning the stored value."""
if not isinstance(body, dict):
raise HTTPException(status_code=422, detail="Body must be a JSON object")
if not isinstance(body.get("version"), int):
raise HTTPException(
status_code=422,
detail="Body must include a numeric 'version' field",
)
surfaces = body.get("surfaces", {})
if not isinstance(surfaces, dict):
raise HTTPException(
status_code=422,
detail="'surfaces' must be an object mapping surface keys to modes",
)
for key, mode in surfaces.items():
if not isinstance(key, str) or not key:
raise HTTPException(
status_code=422,
detail=f"Surface keys must be non-empty strings (got {key!r})",
)
if mode not in _VALID_CARD_MODES:
raise HTTPException(
status_code=422,
detail=(
f"Surface {key!r} has invalid mode {mode!r}; "
f"expected one of {sorted(_VALID_CARD_MODES)}"
),
)
db.set_setting(_CARD_MODES_KEY, body)
return {"ok": True}
@router.delete(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def delete_card_modes(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Delete saved card-mode preferences — every surface reverts to the
frontend default on next load."""
db.set_setting(_CARD_MODES_KEY, {})
return {"ok": True}
# ---------------------------------------------------------------------------
# Daylight timezone (global)
# ---------------------------------------------------------------------------
@@ -51,6 +51,8 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
],
order=preset.order,
tags=preset.tags,
icon=getattr(preset, "icon", "") or "",
icon_color=getattr(preset, "icon_color", "") or "",
created_at=preset.created_at,
updated_at=preset.updated_at,
)
@@ -84,6 +86,8 @@ async def create_scene_preset(
targets=targets,
order=store.count(),
tags=data.tags if data.tags is not None else [],
icon=data.icon or "",
icon_color=data.icon_color or "",
created_at=now,
updated_at=now,
)
@@ -182,6 +186,8 @@ async def update_scene_preset(
order=data.order,
targets=new_targets,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except ValueError as e:
raise HTTPException(
@@ -38,6 +38,8 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
speed=rt.speed if rt else clock.speed,
description=clock.description,
tags=clock.tags,
icon=getattr(clock, "icon", "") or "",
icon_color=getattr(clock, "icon_color", "") or "",
is_running=rt.is_running if rt else True,
elapsed_time=rt.get_time() if rt else 0.0,
created_at=clock.created_at,
@@ -75,6 +77,8 @@ async def create_sync_clock(
speed=data.speed,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
fire_entity_event("sync_clock", "created", clock.id)
return _to_response(clock, manager)
@@ -120,6 +124,8 @@ async def update_sync_clock(
speed=data.speed,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
# Hot-update runtime speed
if data.speed is not None:
+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 -43
View File
@@ -45,6 +45,21 @@ logger = get_logger(__name__)
router = APIRouter()
def _template_to_response(t) -> TemplateResponse:
return TemplateResponse(
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "",
)
# ===== CAPTURE TEMPLATE ENDPOINTS =====
@@ -57,19 +72,7 @@ async def list_templates(
try:
templates = template_store.get_all_templates()
template_responses = [
TemplateResponse(
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at,
description=t.description,
)
for t in templates
]
template_responses = [_template_to_response(t) for t in templates]
return TemplateListResponse(
templates=template_responses,
@@ -100,19 +103,12 @@ async def create_template(
engine_config=template_data.engine_config,
description=template_data.description,
tags=template_data.tags,
icon=template_data.icon,
icon_color=template_data.icon_color,
)
fire_entity_event("capture_template", "created", template.id)
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
return _template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -138,16 +134,7 @@ async def get_template(
except ValueError:
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
return _template_to_response(template)
@router.put(
@@ -168,19 +155,12 @@ async def update_template(
engine_config=update_data.engine_config,
description=update_data.description,
tags=update_data.tags,
icon=update_data.icon,
icon_color=update_data.icon_color,
)
fire_entity_event("capture_template", "updated", template_id)
return TemplateResponse(
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at,
description=template.description,
)
return _template_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -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,
@@ -64,6 +66,8 @@ _RESPONSE_MAP = {
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,
value=s.value,
@@ -73,6 +77,8 @@ _RESPONSE_MAP = {
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,
waveform=s.waveform,
@@ -85,6 +91,8 @@ _RESPONSE_MAP = {
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,
audio_source_id=s.audio_source_id,
@@ -100,6 +108,8 @@ _RESPONSE_MAP = {
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,
speed=s.speed,
@@ -114,6 +124,8 @@ _RESPONSE_MAP = {
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,
color=list(s.color),
@@ -123,6 +135,8 @@ _RESPONSE_MAP = {
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,
colors=[list(c) for c in s.colors],
@@ -135,6 +149,8 @@ _RESPONSE_MAP = {
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,
schedule=s.schedule,
@@ -144,6 +160,8 @@ _RESPONSE_MAP = {
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,
ha_source_id=s.ha_source_id,
@@ -158,6 +176,8 @@ _RESPONSE_MAP = {
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,
value_source_id=s.value_source_id,
@@ -169,6 +189,8 @@ _RESPONSE_MAP = {
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,
color_strip_source_id=s.color_strip_source_id,
@@ -180,6 +202,8 @@ _RESPONSE_MAP = {
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,
metric=s.metric,
@@ -191,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,
),
}
@@ -204,6 +244,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name,
description=source.description,
tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
picture_source_id=source.picture_source_id,
@@ -218,6 +260,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name,
description=source.description,
tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
schedule=source.schedule,
@@ -233,6 +277,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
name=source.name,
description=source.description,
tags=source.tags,
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
value=getattr(source, "value", 1.0),
@@ -39,6 +39,8 @@ def _to_response(source: WeatherSource) -> WeatherSourceResponse:
update_interval=d["update_interval"],
description=d.get("description"),
tags=d.get("tags", []),
icon=getattr(source, "icon", "") or "",
icon_color=getattr(source, "icon_color", "") or "",
created_at=source.created_at,
updated_at=source.updated_at,
)
@@ -79,6 +81,8 @@ async def create_weather_source(
update_interval=data.update_interval,
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))
@@ -125,6 +129,8 @@ async def update_weather_source(
update_interval=data.update_interval,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
+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):
+20
View File
@@ -12,6 +12,16 @@ class AssetUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name")
description: Optional[str] = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AssetResponse(BaseModel):
@@ -26,6 +36,16 @@ class AssetResponse(BaseModel):
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -17,6 +17,16 @@ class AudioProcessingTemplateCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioProcessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class AudioProcessingTemplateUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioProcessingTemplateResponse(BaseModel):
@@ -42,6 +62,16 @@ class AudioProcessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioProcessingTemplateListResponse(BaseModel):
@@ -19,6 +19,16 @@ class _AudioSourceResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class CaptureAudioSourceResponse(_AudioSourceResponseBase):
@@ -53,6 +63,16 @@ class _AudioSourceCreateBase(BaseModel):
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class CaptureAudioSourceCreate(_AudioSourceCreateBase):
@@ -87,6 +107,16 @@ class _AudioSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
@@ -16,6 +16,16 @@ class AudioTemplateCreate(BaseModel):
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioTemplateUpdate(BaseModel):
@@ -26,6 +36,16 @@ class AudioTemplateUpdate(BaseModel):
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioTemplateResponse(BaseModel):
@@ -39,6 +59,16 @@ class AudioTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AudioTemplateListResponse(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
@@ -67,6 +85,16 @@ class AutomationCreate(BaseModel):
None, description="Scene preset for fallback deactivation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AutomationUpdate(BaseModel):
@@ -84,6 +112,16 @@ class AutomationUpdate(BaseModel):
None, description="Scene preset for fallback deactivation"
)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class AutomationResponse(BaseModel):
@@ -108,6 +146,16 @@ class AutomationResponse(BaseModel):
last_deactivated_at: Optional[datetime] = Field(
None, description="Last time this automation was deactivated"
)
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -17,6 +17,16 @@ class ColorStripProcessingTemplateCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ColorStripProcessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class ColorStripProcessingTemplateUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ColorStripProcessingTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class ColorStripProcessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ColorStripProcessingTemplateListResponse(BaseModel):
@@ -95,6 +95,16 @@ class _CSSResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PictureCSSResponse(_CSSResponseBase):
@@ -112,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")
@@ -230,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")],
@@ -248,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"),
]
@@ -266,6 +284,16 @@ class _CSSCreateBase(BaseModel):
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PictureCSSCreate(_CSSCreateBase):
@@ -283,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")
@@ -414,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")],
@@ -432,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"),
]
@@ -450,6 +486,16 @@ class _CSSUpdateBase(BaseModel):
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PictureCSSUpdate(_CSSUpdateBase):
@@ -467,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")
@@ -596,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")],
@@ -614,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"),
]
+163 -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"
@@ -86,6 +146,17 @@ class DeviceCreate(BaseModel):
None,
description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)",
)
# Custom card icon (frontend display only)
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the card's channel accent.",
)
class DeviceUpdate(BaseModel):
@@ -115,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")
@@ -122,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")
@@ -140,6 +235,43 @@ class DeviceUpdate(BaseModel):
None, description="Ordered list of child device IDs (for group device type)"
)
group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent")
# Custom card icon
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
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):
@@ -272,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")
@@ -295,6 +454,8 @@ class DeviceResponse(BaseModel):
default_factory=list, description="Ordered list of child device IDs (for group device type)"
)
group_mode: str = Field(default="sequence", description="Group mode: sequence or independent")
icon: str = Field(default="", description="Icon id from the curated icon library")
icon_color: str = Field(default="", description="Optional CSS color override for the icon")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -42,6 +42,16 @@ class GameIntegrationCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class GameIntegrationUpdate(BaseModel):
@@ -56,6 +66,16 @@ class GameIntegrationUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: Optional[List[str]] = Field(None, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class GameIntegrationResponse(BaseModel):
@@ -71,6 +91,16 @@ class GameIntegrationResponse(BaseModel):
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Integration description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
class GameIntegrationListResponse(BaseModel):
@@ -20,6 +20,16 @@ class GradientCreate(BaseModel):
stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class GradientUpdate(BaseModel):
@@ -29,6 +39,16 @@ class GradientUpdate(BaseModel):
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class GradientResponse(BaseModel):
@@ -42,6 +62,16 @@ class GradientResponse(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class GradientListResponse(BaseModel):
@@ -18,6 +18,16 @@ class HomeAssistantSourceCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class HomeAssistantSourceUpdate(BaseModel):
@@ -30,6 +40,16 @@ class HomeAssistantSourceUpdate(BaseModel):
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class HomeAssistantSourceResponse(BaseModel):
@@ -44,6 +64,16 @@ class HomeAssistantSourceResponse(BaseModel):
entity_count: int = Field(default=0, description="Number of cached entities")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
token: Optional[str] = Field(
@@ -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
+30
View File
@@ -18,6 +18,16 @@ class MQTTSourceCreate(BaseModel):
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class MQTTSourceUpdate(BaseModel):
@@ -32,6 +42,16 @@ class MQTTSourceUpdate(BaseModel):
base_topic: Optional[str] = Field(None, description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class MQTTSourceResponse(BaseModel):
@@ -48,6 +68,16 @@ class MQTTSourceResponse(BaseModel):
connected: bool = Field(default=False, description="Whether the broker connection is active")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -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)
# =====================================================================
@@ -55,6 +69,8 @@ class _OutputTargetResponseBase(BaseModel):
name: str = Field(description="Target name")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: str = Field(default="", description="Custom icon id from the curated icon library")
icon_color: str = Field(default="", description="Optional CSS color override for the icon")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -81,7 +97,19 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
class HALightOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
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 entities).",
)
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'); "
"must reference a value source whose return_type='color'.",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings"
@@ -98,12 +126,63 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
min_brightness_threshold: Optional[BindableFloatInput] = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Literal["none", "turn_off", "restore"] = Field(
default="none",
description="What to do with mapped lights when the target stops: "
"'none' (leave as-is), 'turn_off', or 'restore' (revert to state captured at start).",
)
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"),
]
@@ -119,6 +198,12 @@ class _OutputTargetCreateBase(BaseModel):
name: str = Field(description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None, max_length=64, description="Custom icon id from the curated icon library"
)
icon_color: Optional[str] = Field(
None, max_length=32, description="Optional CSS color override for the icon"
)
class LedOutputTargetCreate(_OutputTargetCreateBase):
@@ -160,7 +245,18 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
class HALightOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: str = Field(default="", description="Home Assistant source ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
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 entities).",
)
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)"
)
@@ -180,12 +276,64 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
stop_action: Literal["none", "turn_off", "restore"] = Field(
default="none",
description="Finalization on stop: 'none', 'turn_off', or 'restore'.",
)
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"),
]
@@ -201,6 +349,16 @@ class _OutputTargetUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Custom icon id; pass empty string to clear and inherit from device.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon; empty string clears.",
)
class LedOutputTargetUpdate(_OutputTargetUpdateBase):
@@ -229,7 +387,15 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase):
class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
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)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings"
@@ -246,12 +412,53 @@ class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
min_brightness_threshold: Optional[BindableFloatInput] = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Optional[Literal["none", "turn_off", "restore"]] = Field(
None, description="Finalization on stop: 'none', 'turn_off', or 'restore'."
)
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"),
]
@@ -17,6 +17,16 @@ class PatternTemplateCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PatternTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class PatternTemplateUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PatternTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class PatternTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PatternTemplateListResponse(BaseModel):
@@ -19,6 +19,16 @@ class _PictureSourceResponseBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class RawPictureSourceResponse(_PictureSourceResponseBase):
@@ -72,6 +82,16 @@ class _PictureSourceCreateBase(BaseModel):
name: str = Field(description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class RawPictureSourceCreate(_PictureSourceCreateBase):
@@ -127,6 +147,16 @@ class _PictureSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class RawPictureSourceUpdate(_PictureSourceUpdateBase):
@@ -17,6 +17,16 @@ class PostprocessingTemplateCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PostprocessingTemplateUpdate(BaseModel):
@@ -28,6 +38,16 @@ class PostprocessingTemplateUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PostprocessingTemplateResponse(BaseModel):
@@ -40,6 +60,16 @@ class PostprocessingTemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class PostprocessingTemplateListResponse(BaseModel):
@@ -23,6 +23,16 @@ class ScenePresetCreate(BaseModel):
None, description="Target IDs to capture (all if omitted)"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ScenePresetUpdate(BaseModel):
@@ -36,6 +46,16 @@ class ScenePresetUpdate(BaseModel):
description="Update target list: keep state for existing, capture fresh for new, drop removed",
)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class ScenePresetResponse(BaseModel):
@@ -47,6 +67,16 @@ class ScenePresetResponse(BaseModel):
targets: List[TargetSnapshotSchema]
order: int
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
created_at: datetime
updated_at: datetime
@@ -13,6 +13,16 @@ class SyncClockCreate(BaseModel):
speed: float = Field(default=1.0, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class SyncClockUpdate(BaseModel):
@@ -22,6 +32,16 @@ class SyncClockUpdate(BaseModel):
speed: Optional[float] = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class SyncClockResponse(BaseModel):
@@ -32,6 +52,16 @@ class SyncClockResponse(BaseModel):
speed: float = Field(description="Speed multiplier")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
is_running: bool = Field(True, description="Whether clock is currently running")
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
created_at: datetime = Field(description="Creation timestamp")
@@ -14,6 +14,16 @@ class TemplateCreate(BaseModel):
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class TemplateUpdate(BaseModel):
@@ -24,6 +34,16 @@ class TemplateUpdate(BaseModel):
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
)
class TemplateResponse(BaseModel):
@@ -37,6 +57,12 @@ class TemplateResponse(BaseModel):
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
None, max_length=64, description="Icon id from the curated icon library."
)
icon_color: Optional[str] = Field(
None, max_length=32, description="Optional CSS color override for the icon."
)
class TemplateListResponse(BaseModel):
@@ -17,6 +17,16 @@ class _ValueSourceResponseBase(BaseModel):
name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -141,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")],
@@ -156,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"),
]
@@ -171,6 +193,16 @@ class _ValueSourceCreateBase(BaseModel):
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class StaticValueSourceCreate(_ValueSourceCreateBase):
@@ -290,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")],
@@ -305,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"),
]
@@ -320,6 +363,16 @@ class _ValueSourceUpdateBase(BaseModel):
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class StaticValueSourceUpdate(_ValueSourceUpdateBase):
@@ -433,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")],
@@ -448,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"),
]
@@ -25,6 +25,16 @@ class WeatherSourceCreate(BaseModel):
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
)
class WeatherSourceUpdate(BaseModel):
@@ -44,6 +54,16 @@ class WeatherSourceUpdate(BaseModel):
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class WeatherSourceResponse(BaseModel):
@@ -60,6 +80,16 @@ class WeatherSourceResponse(BaseModel):
update_interval: int = Field(description="API poll interval in seconds")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
+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(cfg: "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__)
cfg = cfg or get_config()
pw = cfg.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

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