Compare commits

...

33 Commits

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

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

Full backend suite: 1551 pass / 1 skip / 0 errors. Ruff clean.
2026-05-26 00:26:36 +03:00
alexei.dolgolyov f6486f9b34 perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings
- dashboard: only destroy/recreate FPS charts for added/removed/detached
  targets; skip the history fetch when local samples already exist.
  Drops sync-clock `is_running` from the structure signature so toggles
  don't trigger a full rebuild; route clock/automation refresh through
  the in-place path.
- perf-charts: cache SVG skeleton per spark host and mutate node
  attributes instead of rewriting `innerHTML` every poll. Memoize
  patches/devices rendering by content signature so unchanged ticks
  no longer restart CSS animations. Skip render for env-hidden cards.
- perf-charts: switch `/system/performance` poll to `fetchWithAuth`,
  re-read `dashboardPollInterval` per tooltip move, and route the
  remaining hardcoded English strings ("no captures", "{n} total",
  "{rate} skipped/s", tooltip age, metric labels) through `t()`.
- locales: add `perf.no_captures`, `perf.captures_count`,
  `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`,
  `perf.tip.now`, `perf.tip.ago` in en/ru/zh.
2026-05-26 00:12:29 +03:00
alexei.dolgolyov 48dbdb90e9 docs(review-todo): check off items addressed in 2026-05-23 autonomous pass
Mark devices.py PATCH fix, WLED route-level test, IPv6 regression
test, IconSelect XSS audit, PEP-604 sweep, magic-number constants,
api/auth except specificity, and the (window as any) static-access
cleanup as done. Defer items are unchanged: performance items keep
their "profile first" caveat, Hue cert pinning + CSP keep the design-
sensitive note, architecture refactors keep the multi-day banner,
and i18n parity is now annotated with the exact missing-key counts
(328 ru / 325 zh) so the next translator pass has a clear scope.
2026-05-23 01:22:41 +03:00
alexei.dolgolyov 003517247f refactor(types): migrate (window as any) statics to typed window globals
59 sites across 19 feature modules switched from
`(window as any).foo` to the typed `window.foo` form against
global-types.d.ts. The 7 remaining sites use dynamic string indexing
(`window[fnName]`) where a typed access is impossible — those keep
the narrow cast and are documented as the legitimate exception in
the typedef file's header.

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

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

test_url_scheme grows public IPv6 (Cloudflare / Google / Quad9 DNS),
bracketed-form, and ULA cases. Adds an explicit pin for the Python
ipaddress documentation-prefix quirk (2001:db8::/32 is is_private,
so it routes to http:// even though some audits colloquially call it
"public").
2026-05-23 01:13:44 +03:00
alexei.dolgolyov 0dd8d430b9 fix(devices): preserve existing URL on PATCH-without-url
When a PATCH omits `url` (rename / icon-only edit), normalized_url
arrived at the processor as None and the manager kept whatever it had
cached — or refused to re-sync if it had nothing. Fall back to
existing.url so the processor is always told the current address.
Surfaced by the production-review backlog.
2026-05-23 01:13:13 +03:00
alexei.dolgolyov fd46c51dba docs: TODO + CLAUDE.md notes + locale keys for new features
TODO.md grows the device-support follow-up roadmap. CLAUDE.md trims a
stale section. en/ru/zh locales add the strings used by the new
HTTP-endpoint editor, MiniSelect labels, automations expansion, and
value-source kinds. Ru/zh parity for the older keys is tracked
separately in REVIEW_TODO.md.
2026-05-23 00:50:31 +03:00
alexei.dolgolyov ddae5719cf chore(frontend-infra): inbound-event allowlist + storage/state touch-ups
events-ws gains an inbound-event allowlist matching the new server-side
allowlist; test_events_ws_parity pins the two lists in sync.
state + storage modules and the streams / integrations /
z2m-light-targets / streams-*-templates editors absorb the
closeIfPristine guard alongside small UX fixes. css-editor template
picks up the new MiniSelect markup for the filter-kind picker.
2026-05-23 00:50:15 +03:00
alexei.dolgolyov 898912f8b1 chore(backend): MQTT/WLED/devices/capture/utils + api routes hardening
Bundle the remaining backend touch-ups that the production review
landed individually as small surgical edits across many modules:
- MQTT runtime: fire-and-forget task tracking + drain resilience.
- mqtt_source + store + storage/color_strip_source: secret_box
  encryption for credentials with auto-migration of plaintext fields.
- devices/discovery_watcher: task tracking on watcher start/stop.
- devices/wled_client + wled_provider: URL scheme inference helper
  applied at the create/update boundary so bare hostnames stay valid.
- core/capture/screen_capture: hardened error paths.
- core/processing (mapped/processed/processor_manager/video/wled_target):
  smaller follow-throughs from the registry refactor that landed
  earlier on the branch.
- utils/safe_source + utils/file_ops + utils/__init__: shared URL +
  IP classification helpers + larger streaming upload size caps.
- api/auth: WebSocket Origin allow-list + /docs auth-gate.
- api/dependencies: register the new HTTP-endpoint store.
- api/routes (assets, backup, webhooks): streaming-upload caps +
  asyncio.gather return_exceptions on broadcast loops.
- tests/test_api + tests/e2e/test_backup_flow: cover the new caps and
  the Origin allow-list.
2026-05-23 00:50:01 +03:00
alexei.dolgolyov 45d12b2811 feat(update-service): SSRF-validated redirects + restart hardening
update_service grows explicit URL validation on the redirect chain so a
hostile mirror can't bounce the updater to a private IP. restart.ps1
gets stricter argument handling and clearer log lines.
default_config.yaml exposes the new toggles. test_system_routes pins
the new behaviour.
2026-05-23 00:49:18 +03:00
alexei.dolgolyov 826e680f37 refactor(color-strip): rename static -> single + frontend follow-through
The "static" source kind always rendered a SINGLE color and the name
confused new code paths. Rename the module + kind to "single". Storage
keeps backward-compatible serialisation. Frontend color-strip cards /
gradient / index / test modules and the affected tests follow the new
name.
2026-05-23 00:49:00 +03:00
alexei.dolgolyov 737fd72b73 feat(value-sources): extend storage + schema + UI alongside new kinds
Storage model + Pydantic schema + route gain fields for the value-source
kinds introduced by the per-type factory refactor. Frontend editor adds
inputs for them and the modal template grows new field rows.
2026-05-23 00:48:48 +03:00
alexei.dolgolyov 3fe66d80cb feat(automations): expand automation rules + UI + engine coverage
Storage model + Pydantic schema + route surface gain the new rule
shapes the engine already supports. Frontend automations editor grows
the matching inputs. New core/test_automation_engine.py pins the
dispatch table rules behind ~285 lines of unit coverage.
2026-05-23 00:48:19 +03:00
alexei.dolgolyov f03cb303c3 feat(modal): closeIfPristine save-guard + per-editor adoption
Modal gains closeIfPristine(entityId): when editing an existing entity
and no tracked field has changed, the helper force-closes the modal
silently and returns true so the caller can skip the PUT and the
misleading "updated" toast. Each editor's save handler now early-returns
on the no-op edit path: advanced-calibration, assets,
audio-processing-templates, audio-sources, calibration, devices,
game-integration, ha-light-targets, home-assistant-sources,
mqtt-sources, pattern-templates, scene-presets, sync-clocks, targets,
weather-sources.
2026-05-23 00:48:00 +03:00
alexei.dolgolyov 9ff83bd6ca feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals
MiniSelect replaces the forbidden plain <select> in editors where the
option list isn't large enough to justify the full IconSelect grid. It
shares the IconSelect look-and-feel (chip + dropdown panel, keyboard
nav, search). IconSelect grows an explicit HTML-escape for item labels
and keeps `item.icon` documented as a trusted-SVG sink for callers
that build the icon string from constants. global-types.d.ts gives
existing window.* accesses real types so feature modules can stop
falling back to `(window as any)`. The modal.css additions style the
two selectors and the new dropdown panels.
2026-05-23 00:47:45 +03:00
alexei.dolgolyov d6cc80074d feat(http-endpoints): introduce HTTP endpoint output target stack
New output kind that POSTs the current strip frame to a user-configured
HTTP endpoint, alongside WLED / MQTT / Hue. Stack mirrors the existing
output-target shape end-to-end: storage model + store, FastAPI router +
Pydantic schemas, JS feature module + modal template, router wiring in
api/__init__.py and the modal include in index.html. Tests cover both
the routes and the store.
2026-05-23 00:47:31 +03:00
alexei.dolgolyov 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
345 changed files with 13265 additions and 3855 deletions
+3
View File
@@ -97,3 +97,6 @@ Thumbs.db
.DS_Store
# Added by code-review-graph
.code-review-graph/
# vex semantic-search embedding cache (auto-downloaded on first --semantic run)
.fastembed_cache/
+4 -1
View File
@@ -6,7 +6,10 @@ repos:
args: [--line-length=100, --target-version=py311]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
# Bumped from v0.8.0 so the hook recognises UP045
# (non-pep604-annotation-optional), which the v0.13+ ruff split off
# from UP007. Pyproject.toml extend-selects both rules.
rev: v0.15.12
hooks:
- id: ruff
args: [--line-length=100, --target-version=py311]
+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.
+146 -22
View File
@@ -1,43 +1,167 @@
## v0.6.1 (2026-05-10)
## v0.7.0 (2026-05-26)
A device-support release: **seven new device families**, a unified **pairing UX**,
a brand-new **HTTP-endpoint** output type, **multi-broker MQTT + Zigbee2MQTT**
support, a major **shutdown / data-safety** fix, and a deep architectural
refactor pass that landed registry patterns for every dispatch hot path.
### Features
- 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))
#### New device types
- **DDP** — standalone Open-Pixel-Control-style target for Pixelblaze / ESPixelStick / xLights / Falcon endpoints, port 4048 ([8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a))
- **Yeelight** — Xiaomi/Yeelight bulbs and lightstrips over JSON-RPC on port 55443, SSDP discovery ([4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005))
- **WiZ Connected** — Philips WiZ smart bulbs over UDP on port 38899, broadcast discovery ([ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b))
- **LIFX** — LIFX bulbs and lightstrips over the binary LIFX LAN protocol on port 56700 ([8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490))
- **Govee LAN** — Govee Wi-Fi bulbs and ambient kits, multicast discovery (requires "LAN Control" enabled in the Govee Home app) ([887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d))
- **Open Pixel Control (OPC)** — Fadecandy boards, xLights/Falcon, OPC bridges, port 7890 with channel addressing ([31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a))
- **Nanoleaf** — Light Panels / Canvas / Shapes / Lines / Elements over the documented HTTP REST API on port 16021 ([426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a))
#### New output type
- **HTTP endpoint output target** — POST live strip frames to any user-configured HTTP endpoint, alongside WLED / MQTT / Hue. Full editor + storage + routes ([d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800))
#### Pairing flow
- Generic **pairing UX scaffold** — 30-second SVG ring + countdown, instructions, retry/cancel. First concrete consumer is Nanoleaf; Tuya/Twinkly slot into the same shape later ([2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680))
#### MQTT / Zigbee2MQTT
- **Multi-broker MQTT** + new **Zigbee2MQTT light output target** sharing the HA-Light editor. Legacy single-broker YAML/env config auto-migrates to a "Default Broker" MQTTSource on startup ([530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c))
#### Editor experience
- **Live preview** for color-strip sources of every type that can render without external calibration (audio, math_wave, weather, game_event, api_input, mapped, composite, processed) ([337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c))
- **Expanded automations** — new rule shapes + matching UI inputs + 285 lines of dispatch coverage ([3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8))
- **Expanded value sources** — storage + schema + UI for the new value-source kinds the per-type factory refactor introduced ([737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72))
- **Card icon picker expanded** from 44 → 120 icons across 5 new categories (weather, nature, controls, status, office) ([cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94))
- **closeIfPristine** modal save-guard — editing an unchanged entity now silently closes the modal instead of firing a misleading "updated" toast ([f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30))
- New **MiniSelect** primitive for compact dropdowns that don't justify the full IconSelect grid; **IconSelect** gains a defence-in-depth XSS sanitiser on the icon channel ([9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd), [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138))
#### Updater
- **SSRF-validated redirect chain** in the update service so a hostile mirror can't bounce the updater to a private IP. Stricter `restart.ps1` argument handling + clearer logs ([45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2))
### Bug Fixes
- 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))
- **Survive PC restart** — SQLite was running WAL with `synchronous=NORMAL` and `Database.close()` was never called, so an unclean Windows shutdown rolled the DB back to the last checkpoint and silently lost recent edits. Now uses `synchronous=FULL` + `wal_autocheckpoint=100` + explicit `wal_checkpoint(TRUNCATE)` on close, and a hidden WM_QUERYENDSESSION / WM_ENDSESSION window keeps Windows from force-killing the process before the lifespan can finish ([e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3))
- **Devices PATCH preserves URL** — PATCH-without-`url` (rename / icon-only) used to drop the address into the processor as None ([0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43))
- **HA Light brightness scale** — `_send_entity_color` was double-applying `brightness_scale` below 1 (quartered output for a half-scale) and skipping it above 1 (boost lost). Now one `clamp(max(r,g,b) * bs * vs, 0, 255)` pass with regression coverage ([ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60))
- **Dashboard "MODIFIED" badge** no longer fires retroactively on un-edited legacy layouts — `userModified` is now driven by actual edits, not deep-equal drift from defaults ([e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d))
- **Transport-bar uptime** repaints on `/health` response instead of waiting up to ~10 s for the next poll ([f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e))
- **Pre-merge device-support review pass** — `update_device` no longer double-encrypts secrets in memory; `GET /devices` strips paired-only secrets behind boolean flags; SSRF validation on every new driver; corrupt-envelope decrypt returns `""` instead of deleting the device row; `update_device` URL trim matches create; Govee discovery port-4002 collision serialised behind a module lock; Nanoleaf mDNS scan cleans up tasks on cancel; pair endpoint stops logging userinfo / exception bodies ([0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78))
- **value_source factory contract** — `_build_game_event` raises `NotImplementedError` (preserves the historical store contract) and `create_source` runs `build_source` before `_check_name_unique` so an invalid `source_type` raises the right error ([c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb))
- **`utils/url_scheme` + `utils/net_classify`** were referenced but untracked on a clean checkout — server failed to start with `ModuleNotFoundError`. Now committed ([7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6))
### Performance
- **Capture hot paths vectorised** — WGC swaps per-frame ~30 MB BGRA→RGB fancy-index allocations for `cv2.cvtColor` into a 3-slot pre-allocated pool; MSS uses `screenshot.raw + cv2.cvtColor` with 256-byte change-detection; DXcam/BetterCam fixes a silent name-mangling factory leak; dominant-colour reduction is ~10× faster via packed-RGB `np.bincount` ([f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0))
- **Event-driven frame hand-off** — `LiveStream` gains a `frame_id` + `Condition`, consumers wait instead of polling, ring buffer grows 3 → 5 slots, `_blend_u16` uses `cv2.addWeighted`. Up to one `frame_time` of glass-to-LED latency saved at matched FPS ([ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81))
- **WLED brightness threshold** caches per-frame `np.max` keyed on frame identity instead of reducing the LED array every loop ([6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6))
- **Dashboard FPS charts** now diff target ids and only recreate added/removed/detached charts (skipping the history fetch when local samples already exist), and spark SVGs are mutated in place instead of `innerHTML`-rewritten every poll. Memoised patches/devices rendering by content signature so unchanged ticks no longer restart CSS animations ([f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9))
---
### Development / Internal
#### CI/Build
#### Architecture audit — registry patterns everywhere
- Android: fail-fast on missing release keystore before SDK setup ([a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b))
- **Color-strip stream dispatch** — `ColorStripStreamManager.acquire()` and `ws_stream._create_stream()` now share a `STREAM_BUILDERS` registry keyed by source type, with import-time coverage assertion against `_SOURCE_TYPE_MAP`. CSS response builder gets the same treatment ([563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac))
- **Value-source create / update** — `ValueSourceStore.create_source` shrinks from ~260 → ~25 lines via per-type builder/applier functions in a new `storage.value_source_factories` module ([3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e))
- **SystemMetricsValueStream** — three parallel `if/elif` chains collapse into a `MetricSpec(name, read_psutil, read_fallback, normalize, prime)` registry in `core.processing.metric_readers` ([9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346))
- **Automation engine** — per-rule-type bodies become `_handle_<kind>` methods, dispatch table built once at class-creation, unknown-type fallback logs instead of silently returning False ([98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d))
- **Effect renderer dispatch** — `@_effect_renderer("fire")` decorators + class-level `_RENDERERS` dict replace per-frame dict-rebuild + silent fire fallback ([97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c))
- **Output-target response builders** — `isinstance` ladder + silent fabricated-LED fallback replaced with `_TARGET_RESPONSE_BUILDERS` dict and a runtime `RuntimeError` for unknown subclasses ([2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb))
- **Versioned data migrations** — replaces a naked `blob.replace(...)` migration with `storage.data_migrations.MigrationRunner` backed by a `data_migrations` audit table and atomic transactions ([563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac))
#### Chores
#### Dedup / refactor
- Clean up `cfg` abbreviation and stale TODO link ([e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4))
- **Edge-to-LED kernels** in `PixelMapper` + `AdvancedPixelMapper` deduped into a shared `core.capture.edge_interpolation` module ([5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db))
- **HA/Z2M `_swap_color_source`** unified behind a shared `light_target_helpers.swap_color_source` helper ([29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf))
- **Single-pixel `_average_color`** lifted out of 6 LED drivers into `core.devices.pixel_reduce.average_color` ([cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba))
- **Static → single rename** for the color-strip source kind. Storage keeps backward-compatible serialisation ([826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680))
- **Bindable types** extracted into `types/bindable.ts`; the wider `types.ts` god-module split is staged for a follow-up frontend sprint ([05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee))
- **WebSocket auth** — 11 `except Exception` sites around handshake replaced with a narrow `_WS_SEND_BENIGN_EXC` tuple; receive path adds explicit observability ([ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88))
- **Backend hardening bundle** — MQTT task tracking + drain resilience, credential encryption with auto-migration, devices watcher task tracking, WLED scheme inference at boundaries, streaming-upload caps, `asyncio.gather(return_exceptions=True)` on broadcast loops, WebSocket Origin allow-list, `/docs` auth-gate ([898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f))
- **Frontend infra** — inbound-event allowlist mirroring the server side, `closeIfPristine` adoption across editors, MiniSelect markup for filter pickers ([ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571))
- **PEP-604 union sweep** — `ruff --select UP007,UP045 --fix` converted ~1760 sites from `Optional[T]` / `Union[X, Y]` to `T | None` / `X | Y`. Hooks bumped to ruff v0.15.12 to recognise UP045 ([888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd))
- **Typed window globals** — 59 `(window as any).foo` sites across 19 feature modules switched to typed `window.foo` against `global-types.d.ts` ([0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172))
- **Processing magic numbers** lifted to named module constants so tests can monkeypatch them ([d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f))
- **`Database.ensure_open()`** — module-level singleton reopens cleanly across lifespan cycles, fixing 65 spurious `sqlite3.ProgrammingError` setup failures on Windows pytest aggregate runs ([f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25))
#### Tests
- WLED URL scheme integration + IPv6 regression coverage ([907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf))
- Lifespan reopen invariants on `Database` ([f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25))
- Hundreds of new tests covering every registry / factory / migration introduced above
#### Tooling / docs
- `.vex.toml` makes vex the project's primary code-search backend with auto-update + semantic embeddings ([06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba))
- `REVIEW_TODO.md` captures audit items deliberately deferred; `TODO.md` records the architecture-audit remainder ([06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba), [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2))
- Locale + CLAUDE.md upkeep alongside the new features ([fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51), [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9), [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af), [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4))
---
<details>
<summary>All Commits</summary>
<summary>All Commits (55)</summary>
| Hash | Message | Author |
|------|---------|--------|
| [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 |
| Hash | Message |
|------|---------|
| [f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25) | fix(storage/database): reopen connection on lifespan restart |
| [f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9) | perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings |
| [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9) | docs(review-todo): check off items addressed in 2026-05-23 autonomous pass |
| [0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172) | refactor(types): migrate (window as any) statics to typed window globals |
| [888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd) | refactor(types): PEP-604 union sweep + UP007/UP045 enforcement |
| [ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88) | refactor(api/auth): narrow WS exception catches + observability log |
| [d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f) | refactor(processing): hot-path magic numbers -> named module constants |
| [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138) | feat(ui/icon-select): defence-in-depth XSS sanitiser on icon channel |
| [907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf) | test(url-scheme): WLED route-level integration + IPv6 regression |
| [0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43) | fix(devices): preserve existing URL on PATCH-without-url |
| [fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51) | docs: TODO + CLAUDE.md notes + locale keys for new features |
| [ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571) | chore(frontend-infra): inbound-event allowlist + storage/state touch-ups |
| [898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f) | chore(backend): MQTT/WLED/devices/capture/utils + api routes hardening |
| [45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2) | feat(update-service): SSRF-validated redirects + restart hardening |
| [826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680) | refactor(color-strip): rename static -> single + frontend follow-through |
| [737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72) | feat(value-sources): extend storage + schema + UI alongside new kinds |
| [3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8) | feat(automations): expand automation rules + UI + engine coverage |
| [f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30) | feat(modal): closeIfPristine save-guard + per-editor adoption |
| [9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd) | feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals |
| [d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800) | feat(http-endpoints): introduce HTTP endpoint output target stack |
| [06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba) | chore(tooling): vex semantic-search config + REVIEW_TODO backlog |
| [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2) | docs: capture architecture-audit remainder for follow-up sessions |
| [2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb) | refactor(output-targets): registry + coverage assertion for response builders |
| [c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb) | fix(value-source): preserve store contract for game_event + error precedence |
| [3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e) | refactor(value-source): per-type factories for create / update dispatch |
| [05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee) | refactor(types): extract bindable primitives into types/bindable.ts (H6 partial) |
| [9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346) | refactor(value-source): MetricSpec registry for SystemMetricsValueStream |
| [98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d) | refactor(automations): rule dispatch via class-level handler table |
| [5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db) | refactor(capture): lift duplicated edge-to-LED kernels into shared module |
| [97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c) | refactor(processing): replace inline effect dispatch with @_effect_renderer registry |
| [29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf) | refactor(processing): dedupe HA/Z2M _swap_color_source via shared helper |
| [563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac) | refactor(storage,processing): kind registries + versioned data migrations |
| [e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3) | fix(shutdown): survive PC restart with WAL fsync + Win32 session-end guard |
| [e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d) | fix(dashboard): stop showing perpetual MODIFIED for un-edited legacy layouts |
| [f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e) | fix(ui): repaint transport-bar uptime as soon as /health responds |
| [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af) | docs: record review-fix pass in TODO.md |
| [0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78) | fix(devices): address pre-merge review findings |
| [7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6) | fix(utils): commit url_scheme + net_classify dependencies |
| [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4) | docs: mark expand-device-support branch ready for merge |
| [cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba) | refactor(devices): extract _average_color to pixel_reduce |
| [426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a) | feat(devices): Nanoleaf OpenAPI target type + first pair-flow user |
| [2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680) | feat(devices): pairing-UX scaffold (Phase 2) |
| [31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a) | feat(devices): Open Pixel Control (OPC) target type |
| [887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d) | feat(devices): Govee LAN target type |
| [8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490) | feat(devices): LIFX LAN target type |
| [ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b) | feat(devices): WiZ Connected LAN target type |
| [4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005) | feat(devices): Yeelight LAN target type |
| [8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a) | feat(devices): standalone DDP target type |
| [337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c) | feat(color-strips): in-editor live preview for all viable source types |
| [530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c) | feat(mqtt): multi-broker MQTT + Zigbee2MQTT light target |
| [6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6) | perf(wled): cache per-frame max-pixel for brightness threshold |
| [ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81) | perf(processing): event-driven frame hand-off and scheduling fixes |
| [f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0) | perf(capture): vectorize hot paths and fix engine bugs |
| [ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60) | fix(ha-light): apply brightness_scale once and respect boost multipliers |
| [cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94) | feat(ui): expand card icon picker (44 -> 120 icons, +5 categories) |
</details>
+180
View File
@@ -0,0 +1,180 @@
# 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).
## Items completed in the follow-up autonomous pass (2026-05-23)
- [x] **devices.py PATCH-without-url processor desync**`update_device`
now falls back to `existing.url` so a rename / icon-only edit
always tells the processor the current address.
- [x] **WLED scheme integration test** on `/api/v1/devices` — covers
bare IPv4 (`http://`), public hostname (`https://`), and trailing-slash
normalisation; lives in `tests/api/routes/test_devices_routes.py`.
- [x] **IPv6 regression test**`tests/test_url_scheme.py` now pins
public IPv6 → `https://`, ULA → `http://`, and documents the
Python-`ipaddress` documentation-prefix classification quirk.
- [x] **IconSelect XSS audit + defence-in-depth** — every caller
audited (all feed `icon` from constants or lookup tables); added
`sanitiseIcon` that rejects `<script>`, `javascript:`, `on*=`,
`<iframe>`, `<embed>`, `<object>` and warns to the console.
- [x] **`Optional[T]``T | None` (PEP 604)** — 55 sites cleaned via
`ruff --fix UP007`. The remaining `Union[…]` aliases for
pixel/colour/device-config typing converted by hand. `UP007` now
lives in `pyproject.toml` so the rule fires on new code.
- [x] **Hot-path magic numbers → named constants**`processed_stream`
gains `_FILTER_RECHECK_EVERY_N_FRAMES`; `wled_target_processor`
gains `_SKIP_REPOLL_SLEEP_SECONDS`, `_DIAGNOSTICS_REPORT_INTERVAL_SECONDS`,
`_CSPT_RECHECK_EVERY_N_ITERATIONS`.
- [x] **`api/auth.py` `except Exception` tightening** — every WS send /
close site is now `except _WS_SEND_BENIGN_EXC` (a narrow tuple of
WebSocketDisconnect / RuntimeError / ConnectionError / OSError).
The auth-receive path catches the same set plus a final
`logger.exception` catch-all for observability on truly unexpected
shapes.
- [x] **`(window as any)` cleanup** — 59 static-property accesses
migrated to typed `window.<name>` against `global-types.d.ts`. The
remaining 7 sites use dynamic string indexing (`window[fnName]`)
and intentionally keep the cast (documented in the typedef file).
---
## 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.
- [x] **`api/auth.py` exception specificity** — done in the 2026-05-23
pass; see top of file.
- [ ] **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** — confirmed **328** keys missing in `ru.json` and
**325** missing in `zh.json` against the canonical English file.
Translation work — needs a native speaker, not a machine-translation
pass. Run `py scripts/diff_locale_keys.py` (or copy the diff block
out of the 2026-05-23 pass log) to get the exact key list.
- [x] **`Optional[T]``T | None`** — done; `UP007` now enforced via
`pyproject.toml` so the rule prevents regressions.
- [ ] **Hot-path `logger.error(f"...")` → `logger.error("... %s", e)`**
lazy-eval — 658 sites flagged by `ruff --select G004`. Deferred
because it is genuinely cosmetic at ERROR level (always emitted)
and the cumulative cost is negligible. Worth doing if/when ruff
gains a safe autofix, or as a Codemod in a dedicated session.
- [x] **Remaining `(window as any)` sites** — 59 migrated to typed
`window.<name>` access; the 7 surviving sites use dynamic string
indexing and are documented as the legitimate exception.
- [x] **Magic numbers → named constants** — done; see `processed_stream`
and `wled_target_processor` constants at the top of each module.
- [ ] **Standardise `from __future__ import annotations`** — partially
mooted by the UP007 cleanup. Files that previously relied on
`Optional`/`Union` no longer need the future import; the few that
already use `__future__` keep it for forward-reference convenience.
A blanket policy would still help — leave as a stylistic followup.
## Test gaps
- [x] **Route-level integration test** for the WLED scheme inference —
done; covers create + update in `tests/api/routes/test_devices_routes.py::TestWLEDSchemeInference`.
- [x] **IPv6 public address regression** — done; pinned in
`tests/test_url_scheme.py` for both bracketless and bracketed forms.
## 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:
- [x] **`icon-select.ts:_buildGrid` `item.icon` is interpolated raw** —
audited; all callers pass project-owned literals or table-lookup
results. Added a runtime sanitiser as defence-in-depth.
- [x] **`devices.py` `manager.update_device_info(device_url=update_data.url)`**
None-on-PATCH path — fixed; now falls back to `existing.url`.
- [ ] **`asyncio.gather` over uncapped client lists** in preview broadcasts
— slow clients block the loop. Already noted under Performance
above; pre-existing.
+165
View File
@@ -1,5 +1,170 @@
# LedGrab TODO
## HTTP polling automation trigger
Goal: a new automation trigger that periodically polls an HTTP endpoint
and activates a scene when the response matches a condition. Split into
three single-responsibility entities so the endpoint can be reused
beyond automations (e.g. as a value-source driving brightness/color):
- `HTTPEndpoint` (storage/http_endpoint.py) — connection definition:
URL + auth + headers + timeout. NO polling cadence; NO extraction.
- `HTTPValueSource` (storage/value_source.py, source_type='http') —
references an endpoint + owns json_path + interval + min/max + EMA
smoothing. Backed by `HTTPValueStream` (core/processing/value_stream.py)
which lives under the existing `ValueStreamManager` (ref-counted,
one poll task per unique value source).
- `HTTPPollRule` (storage/automation.py) — thin: `{value_source_id,
operator, value}`. Reads `stream.get_raw_value()` from the value
source and compares with `_apply_operator`.
Pivoted from a 2-entity shape mid-build (was: HTTPSource+rule with
interval+json_path mushed). The 3-entity shape mirrors HA's pattern
(HomeAssistantSource → HAEntityValueSource → rule).
### Phase 1 — endpoint + value source + thin rule (backend) ✅
- [x] `storage/http_endpoint.py` — `HTTPEndpoint` dataclass with
secret_box auth_token encryption + `__post_init__` plaintext
invariant. NO `default_interval_s` (moved to value source).
- [x] `storage/http_endpoint_store.py` — `HTTPEndpointStore` with
`_migrate_plaintext_tokens()`. ID prefix `htep_`.
- [x] `storage/database.py` — `"http_endpoints"` in `_ENTITY_TABLES`
(replaces the old `"http_sources"`).
- [x] `storage/value_source.py` — added `HTTPValueSource` alongside
`HAEntityValueSource` (endpoint_id, json_path, interval_s,
min/max, smoothing). Registered in `_VALUE_SOURCE_MAP`.
- [x] `storage/value_source_store.py` — CRUD branch for `source_type =
"http"` + new kwargs on create/update.
- [x] `core/processing/value_stream.py` — `HTTPValueStream` with poll
task + `get_value()` (normalized 0-1) + `get_raw_value()` (raw
extracted value). Dispatched in `ValueStreamManager._create_stream`.
Manager now takes `http_endpoint_store` so the stream can resolve
endpoints at fetch time.
### Phase 2 — rule + engine wiring ✅
- [x] `storage/automation.py` — `HTTPPollRule` is now thin: just
`{value_source_id, operator, value}` (no http_source_id, no
json_path on the rule). Legacy keys silently dropped on load.
- [x] `core/automations/automation_engine.py` — drops the standalone
http_poll_manager; takes `value_stream_manager`. Engine
`_sync_value_stream_refs` acquires/releases value streams for
every enabled HTTPPollRule, mirroring the HA/MQTT sync pattern.
`_evaluate_http_poll` reads `stream.get_raw_value()` and applies
the operator. `_apply_operator` kept at module top.
- [x] `api/schemas/automations.py` — RuleSchema fields are now
`value_source_id + operator + value` (dropped http_source_id +
json_path).
- [x] `api/routes/automations.py` — `http_poll` factory updated.
### Phase 3 — CRUD endpoints + wiring ✅
- [x] `api/schemas/http_endpoints.py` — Create/Update/Response/List/Test
(no interval field; that's on the value source).
- [x] `api/routes/http_endpoints.py` — full CRUD + `/test` +
plaintext-http-token warning.
- [x] `api/schemas/value_sources.py` — `HTTPValueSource{Create,Update,Response}`
added to the discriminated unions.
- [x] `api/routes/value_sources.py` — `_RESPONSE_MAP` entry for
`HTTPValueSource`.
- [x] `api/__init__.py` — `http_endpoints_router` registered.
- [x] `api/dependencies.py` — `get_http_endpoint_store` (dropped the
http_poll_manager getter).
- [x] `main.py` — instantiate `HTTPEndpointStore`, pass it through
`ProcessorDependencies`, wire `value_stream_manager` +
`value_source_store` into `AutomationEngine`.
- [x] `core/processing/processor_manager.py` — `ProcessorDependencies`
gains `http_endpoint_store`; threaded into `ValueStreamManager`.
### Phase 4 — tests ✅
- [x] `tests/storage/test_http_endpoint_store.py` — 14 tests (CRUD +
auth_token encryption + headers + case-insensitive Authorization).
- [x] `tests/core/test_automation_engine.py` — `TestApplyOperator` +
`TestHTTPPollRuleEvaluation` (new shape: mock ValueStreamManager
with `_streams` dict) + `TestSyncValueStreamRefs` (acquire /
release / disabled-ignored) + `TestHTTPValueStreamExtraction`
(`_extract_simple_path` now lives in value_stream.py).
- [x] `tests/api/routes/test_http_endpoints_routes.py` — CRUD shape, no
auth_token leak in responses, schema-layer method allowlist,
CRLF / invalid header rejection, `/test` endpoint, LAN policy.
- [x] Removed: `tests/core/test_http_poll_manager.py` (manager deleted —
polling now lives inside `HTTPValueStream`).
- [x] Full suite: 1426 passed, ruff clean.
### Phase 5 — frontend ✅
- [x] `static/js/features/http-endpoints.ts` (new, ~540 LOC) — endpoint
CRUD, modal subclass with dirty-check, headers row editor, test
result rendering, card builder, event delegation. Mirrors
`home-assistant-sources.ts`.
- [x] `templates/modals/http-endpoint-editor.html` (new) — sectioned
rack-panel modal (Identity / Request / Headers / Notes) with
IconSelect method picker, password-toggle on auth token, inline
Test button + result block.
- [x] `static/js/features/value-sources.ts` — added `http` branch with
EntitySelect over `httpEndpointsCache`, edit-data/defaults,
`onValueSourceTypeChange` section toggle, save-payload assembly
+ required-field validation.
- [x] `templates/modals/value-source-editor.html` — new
`#value-source-http-section` with endpoint picker + json_path +
interval + min/max + smoothing.
- [x] `static/js/features/automations.ts` — `http_poll` rule type with
operator IconSelect + value-source EntitySelect; hides Value
field when operator is `exists`.
- [x] `static/js/features/integrations.ts` — `csHTTPEndpoints` section,
tree/tab entry, render + reconcile + delegation paths.
- [x] `static/js/types.ts` — `HTTPEndpoint`, `HTTPMethod`,
`HTTPEndpointListResponse`, `HTTPTestRequest/Response`,
`HTTPValueSource`, `HTTPPollOperator`; extended `RuleType` +
`AutomationRule`.
- [x] `static/js/core/state.ts` — `httpEndpointsCache` (`/http/endpoints`).
- [x] `static/js/core/icons.ts` — `http: P.globe` in
`_valueSourceTypeIcons`.
- [x] `templates/index.html` — includes
`modals/http-endpoint-editor.html`.
- [x] Locales: 77 new keys per file in `en.json` / `ru.json` /
`zh.json` (parity confirmed).
- [x] Verification: `npx tsc --noEmit` clean; `npm run build` clean
(app.bundle.css 366.6kb, app.bundle.js 2.7mb).
### Follow-ups (out of scope for initial PR)
- [ ] **Global concurrency cap / minimum interval.** Each
`HTTPValueStream` runs its own task at `interval_s` (min 1s); no
project-wide cap. Reviewer flagged: pick a min (e.g. 5s) + max
active runtimes (e.g. 32) + shared `httpx.AsyncClient` with
`limits=httpx.Limits(max_connections=N)`.
- [ ] **DNS-rebinding hardening.** `safe_request_bounded` validates
the URL hostname's resolved IPs once; httpx independently
re-resolves. The window is short but not zero. True fix: pin
to the validated IP + set Host header (and SNI for HTTPS). This
affects every outbound caller (`safe_fetch`, weather, image
sources) — handle as a project-wide hardening, not local to
this feature.
- [ ] **`delete_http_endpoint` orphan refs.** When an admin deletes an
endpoint referenced by N value sources, the value-stream task
keeps polling until its source is also deleted. Same shape as
the MQTT defect — fix both together (refuse-with-409 when in
use, or cascade value-source deletion).
- [ ] **Per-endpoint `connected` / last-poll status on the response**
(frontend agent flagged). `HTTPEndpointResponse` has no live
status, unlike HA/MQTT sources. Card LEDs default to "on".
Could aggregate `last_status_code` / `last_error` from all
`HTTPValueStream` instances referencing the endpoint and surface
on `GET /http/endpoints/{id}`.
- [x] **Per-endpoint live `/test` after save** — added `POST
/http/endpoints/{id}/test` (runs stored config server-side so the
auth token never round-trips) and wired a flask-icon test action
on the endpoint card (toasts the result). Custom-headers section
and inline test-result UI in the editor modal also restyled to
match the `.group-child-row` and result-card vocabulary.
- [ ] **Dedicated icon for HTTP value source / endpoint** (frontend
agent flagged). Both use `P.globe` — visually fine in practice
but adding a `cable`/`webhook` glyph in `icon-paths.ts` would
improve differentiation.
## Multi-broker MQTT refactor
Goal: drop the global `MQTTService` / `MQTTConfig`. Every MQTT consumer
+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.1"
versionName = "0.7.0"
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
+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"
+9 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.6.1"
version = "0.7.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
@@ -117,3 +117,11 @@ target-version = ['py311']
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
# E + F are ruff's defaults; UP007 + UP045 enforce PEP-604 `X | Y` and
# `T | None` style so we don't drift back to the legacy `Union[X, Y]` /
# `Optional[T]` imports the REVIEW_TODO mechanical sweep removed.
# Recent ruff versions split the rule — UP007 covers `Union`, UP045
# covers `Optional`.
extend-select = ["UP007", "UP045"]
+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
+1 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
# 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"
_FALLBACK_VERSION = "0.7.0"
def _read_pyproject_version() -> str | None:
+4 -4
View File
@@ -8,11 +8,11 @@ inside an Android application. Sets up Android-specific paths
import asyncio
import os
import threading
from typing import Any, Optional
from typing import Any
_server_thread: Optional[threading.Thread] = None
_server: Optional[Any] = None # uvicorn.Server
_loop: Optional[asyncio.AbstractEventLoop] = None
_server_thread: threading.Thread | None = None
_server: Any | None = None # uvicorn.Server
_loop: asyncio.AbstractEventLoop | None = None
def start_server(data_dir: str, port: int = 8080) -> None:
+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)
+73 -18
View File
@@ -11,13 +11,25 @@ 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"})
# Exceptions that legitimately fire when we try to send / close a WebSocket
# that is already shutting down: the peer dropped, the connect-state moved
# under us, the underlying socket is gone, the JSON encoder choked, etc.
# Keeping this tuple narrow means a genuine programming error (AttributeError,
# TypeError) bubbles up to the caller instead of silently disappearing.
_WS_SEND_BENIGN_EXC: tuple[type[BaseException], ...] = (
WebSocketDisconnect,
RuntimeError,
ConnectionError,
OSError,
)
def is_auth_enabled() -> bool:
@@ -26,15 +38,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 +154,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,12 +181,29 @@ 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 _WS_SEND_BENIGN_EXC:
pass
return None
await websocket.accept()
label = await verify_ws_auth(websocket, timeout=timeout)
if label is None:
try:
await websocket.close(code=WS_AUTH_CLOSE_CODE)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
return label
@@ -221,20 +267,29 @@ async def verify_ws_auth(
# Loopback anonymous: no auth message arrived, but none is required.
try:
await websocket.send_json({"type": "auth_ok"})
except Exception:
except _WS_SEND_BENIGN_EXC:
return None
return "anonymous"
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
except WebSocketDisconnect:
return None
except Exception as exc:
except (RuntimeError, ConnectionError, OSError) as exc:
# The peer hung up mid-handshake or the underlying socket is gone.
# Promote anything outside this set to a hard failure with a stack
# trace so we can see real bugs (decode errors, type errors, …).
logger.debug("WebSocket auth receive error: %s", exc)
return None
except Exception:
# Unexpected — log the full traceback so we can see what we missed
# without leaving the connection half-open. Re-raise nothing; the
# caller will close on the None return.
logger.exception("Unexpected error during WebSocket auth handshake")
return None
# Parse the auth message.
try:
@@ -244,7 +299,7 @@ async def verify_ws_auth(
await websocket.send_json(
{"type": "auth_error", "reason": "invalid JSON in auth message"}
)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -253,7 +308,7 @@ async def verify_ws_auth(
await websocket.send_json(
{"type": "auth_error", "reason": "first message must be {type:'auth'}"}
)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -263,7 +318,7 @@ async def verify_ws_auth(
await websocket.send_json(
{"type": "auth_error", "reason": "token must be a string or null"}
)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -280,7 +335,7 @@ async def verify_ws_auth(
"reason": "LAN access requires an API key",
}
)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -290,13 +345,13 @@ async def verify_ws_auth(
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
try:
await websocket.send_json({"type": "auth_ok"})
except Exception:
except _WS_SEND_BENIGN_EXC:
return None
logger.debug("WebSocket authenticated as: %s", label)
return label
+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,
}
@@ -3,7 +3,7 @@
import asyncio
import threading
import time
from typing import Callable, Optional
from typing import Callable
import numpy as np
from starlette.websockets import WebSocket
@@ -61,8 +61,8 @@ async def stream_capture_test(
websocket: WebSocket,
engine_factory: Callable,
duration: float,
pp_filters: Optional[list] = None,
preview_width: Optional[int] = None,
pp_filters: list | None = None,
preview_width: int | None = None,
) -> None:
"""Run a capture test, streaming intermediate thumbnails and a final full-res frame.
+5 -4
View File
@@ -15,7 +15,7 @@ from ledgrab.api.schemas.assets import (
from ledgrab.config import get_config
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
from ledgrab.utils import get_logger, read_upload_capped
logger = get_logger(__name__)
@@ -93,10 +93,11 @@ async def upload_asset(
config = get_config()
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
data = await file.read()
if len(data) > max_size:
try:
data = await read_upload_capped(file, max_size)
except ValueError:
raise HTTPException(
status_code=400,
status_code=413,
detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
)
@@ -1,7 +1,7 @@
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
import asyncio
from typing import Annotated, Optional
from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
@@ -91,7 +91,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
async def list_audio_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(
source_type: str | None = Query(
None, description="Filter by source_type: capture or processed"
),
store: AudioSourceStore = Depends(get_audio_source_store),
@@ -23,6 +23,7 @@ from ledgrab.storage.automation import (
ApplicationRule,
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
MQTTRule,
Rule,
StartupRule,
@@ -75,6 +76,11 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
state=s.state or "",
match_mode=s.match_mode or "exact",
),
"http_poll": lambda: HTTPPollRule(
value_source_id=s.value_source_id or "",
operator=s.operator or "equals",
value=s.value or "",
),
}
factory = _SCHEMA_TO_RULE.get(s.rule_type)
if factory is None:
+6 -4
View File
@@ -28,7 +28,7 @@ from ledgrab.config import get_config
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.database import Database, freeze_writes
from ledgrab.utils import get_logger
from ledgrab.utils import get_logger, read_upload_capped
logger = get_logger(__name__)
@@ -133,9 +133,11 @@ async def restore_config(
because restore replaces all configuration including secrets).
"""
require_authenticated(auth)
raw = await file.read()
if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets)
raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)")
_MAX_BACKUP_BYTES = 200 * 1024 * 1024 # 200 MB (ZIP may contain assets)
try:
raw = await read_upload_capped(file, _MAX_BACKUP_BYTES)
except ValueError:
raise HTTPException(status_code=413, detail="Backup file too large (max 200 MB)")
if len(raw) < 100:
raise HTTPException(status_code=400, detail="File too small to be a valid backup")
@@ -9,6 +9,7 @@ from ledgrab.api.schemas.color_strip_sources import (
CompositeCSSResponse,
DaylightCSSResponse,
EffectCSSResponse,
GameEventCSSResponse,
GradientCSSResponse,
KeyColorsCSSResponse,
MappedCSSResponse,
@@ -17,7 +18,7 @@ from ledgrab.api.schemas.color_strip_sources import (
PictureAdvancedCSSResponse,
PictureCSSResponse,
ProcessedCSSResponse,
StaticCSSResponse,
SingleColorCSSResponse,
WeatherCSSResponse,
)
from ledgrab.api.schemas.devices import Calibration as CalibrationSchema
@@ -26,22 +27,7 @@ from ledgrab.core.capture.calibration import (
calibration_to_dict,
)
from ledgrab.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ApiInputColorStripSource,
AudioColorStripSource,
CandlelightColorStripSource,
CompositeColorStripSource,
DaylightColorStripSource,
EffectColorStripSource,
GradientColorStripSource,
KeyColorsColorStripSource,
MappedColorStripSource,
MathWaveColorStripSource,
NotificationColorStripSource,
PictureColorStripSource,
ProcessedColorStripSource,
StaticColorStripSource,
WeatherColorStripSource,
_SOURCE_TYPE_MAP as _STORAGE_TYPE_MAP,
)
from ledgrab.storage.picture_source import (
ProcessedPictureSource,
@@ -94,34 +80,46 @@ def _stops_schema(source) -> list[ColorStopSchema] | None:
return None
# Maps storage class → response builder lambda.
# Maps ``source_type`` string → response builder.
#
# Keying by source_type (rather than type(source)) lets the import-time
# coverage check use the storage registry's keys directly, with no
# inversion or duplicate-class handling for legacy aliases.
_RESPONSE_MAP: dict = {
PictureColorStripSource: lambda s, kw: PictureCSSResponse(
"picture": lambda s, kw: PictureCSSResponse(
**kw,
picture_source_id=s.picture_source_id,
smoothing=s.smoothing.to_dict(),
interpolation_mode=s.interpolation_mode,
calibration=_calibration_schema(s),
),
AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse(
"picture_advanced": lambda s, kw: PictureAdvancedCSSResponse(
**kw,
smoothing=s.smoothing.to_dict(),
interpolation_mode=s.interpolation_mode,
calibration=_calibration_schema(s),
),
StaticColorStripSource: lambda s, kw: StaticCSSResponse(
"single_color": lambda s, kw: SingleColorCSSResponse(
**kw,
color=s.color.to_dict(),
animation=s.animation,
),
GradientColorStripSource: lambda s, kw: GradientCSSResponse(
# Legacy alias: pre-rename rows used "static"; the data migration rewrites
# them on first store load but a stale in-flight instance would still
# carry source_type='static' until the next reload.
"static": lambda s, kw: SingleColorCSSResponse(
**kw,
color=s.color.to_dict(),
animation=s.animation,
),
"gradient": lambda s, kw: GradientCSSResponse(
**kw,
stops=_stops_schema(s),
animation=s.animation,
easing=s.easing,
gradient_id=s.gradient_id,
),
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
"effect": lambda s, kw: EffectCSSResponse(
**kw,
effect_type=s.effect_type,
palette=s.palette,
@@ -132,15 +130,15 @@ _RESPONSE_MAP: dict = {
mirror=s.mirror,
custom_palette=s.custom_palette,
),
CompositeColorStripSource: lambda s, kw: CompositeCSSResponse(
"composite": lambda s, kw: CompositeCSSResponse(
**kw,
layers=[dict(layer) for layer in s.layers],
),
MappedColorStripSource: lambda s, kw: MappedCSSResponse(
"mapped": lambda s, kw: MappedCSSResponse(
**kw,
zones=[dict(z) for z in s.zones],
),
AudioColorStripSource: lambda s, kw: AudioCSSResponse(
"audio": lambda s, kw: AudioCSSResponse(
**kw,
visualization_mode=s.visualization_mode,
audio_source_id=s.audio_source_id,
@@ -153,13 +151,13 @@ _RESPONSE_MAP: dict = {
mirror=s.mirror,
beat_decay=s.beat_decay.to_dict(),
),
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
"api_input": lambda s, kw: ApiInputCSSResponse(
**kw,
fallback_color=s.fallback_color.to_dict(),
timeout=s.timeout.to_dict(),
interpolation=s.interpolation,
),
NotificationColorStripSource: lambda s, kw: NotificationCSSResponse(
"notification": lambda s, kw: NotificationCSSResponse(
**kw,
notification_effect=s.notification_effect,
duration_ms=s.duration_ms.to_dict(),
@@ -172,14 +170,14 @@ _RESPONSE_MAP: dict = {
sound_volume=s.sound_volume.to_dict(),
app_sounds=dict(s.app_sounds),
),
DaylightColorStripSource: lambda s, kw: DaylightCSSResponse(
"daylight": lambda s, kw: DaylightCSSResponse(
**kw,
speed=s.speed.to_dict(),
use_real_time=s.use_real_time,
latitude=s.latitude,
longitude=s.longitude,
),
CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse(
"candlelight": lambda s, kw: CandlelightCSSResponse(
**kw,
color=s.color.to_dict(),
intensity=s.intensity.to_dict(),
@@ -188,18 +186,18 @@ _RESPONSE_MAP: dict = {
wind_strength=s.wind_strength.to_dict(),
candle_type=s.candle_type,
),
ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse(
"processed": lambda s, kw: ProcessedCSSResponse(
**kw,
input_source_id=s.input_source_id,
processing_template_id=s.processing_template_id,
),
WeatherColorStripSource: lambda s, kw: WeatherCSSResponse(
"weather": lambda s, kw: WeatherCSSResponse(
**kw,
weather_source_id=s.weather_source_id,
speed=s.speed.to_dict(),
temperature_influence=s.temperature_influence.to_dict(),
),
KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse(
"key_colors": lambda s, kw: KeyColorsCSSResponse(
**kw,
picture_source_id=s.picture_source_id,
rectangles=[r.to_dict() for r in s.rectangles],
@@ -207,28 +205,67 @@ _RESPONSE_MAP: dict = {
smoothing=s.smoothing.to_dict(),
brightness=s.brightness.to_dict(),
),
MathWaveColorStripSource: lambda s, kw: MathWaveCSSResponse(
"math_wave": lambda s, kw: MathWaveCSSResponse(
**kw,
waves=s.waves,
speed=s.speed.to_dict(),
gradient_id=s.gradient_id,
),
"game_event": lambda s, kw: GameEventCSSResponse(
**kw,
game_integration_id=s.game_integration_id,
idle_color=s.idle_color.to_dict(),
event_mappings=[dict(m) for m in s.event_mappings],
),
}
def _assert_response_map_coverage() -> None:
"""Verify _RESPONSE_MAP has a builder for every kind in storage's registry.
Runs at module import. Surfaces missing builders eagerly instead of
letting a request fall through to a silent / wrong response shape.
Contract note
-------------
This check is **symmetric** (``_RESPONSE_MAP keys == storage_kinds``)
because every kind — sharable or not — needs a response shape. The
sister assertion in
``core/processing/color_strip_kinds.py::_assert_stream_kind_coverage``
is asymmetric because sharable kinds construct their streams via a
different path. Adding a new kind requires keeping all three registries
aligned: storage's ``_SOURCE_TYPE_MAP``, this ``_RESPONSE_MAP``, and
either ``STREAM_BUILDERS`` or ``SHARABLE_KINDS``.
"""
storage_kinds = set(_STORAGE_TYPE_MAP.keys())
builder_kinds = set(_RESPONSE_MAP.keys())
missing = storage_kinds - builder_kinds
extra = builder_kinds - storage_kinds
if missing or extra:
problems = []
if missing:
problems.append(f"missing builders for: {sorted(missing)}")
if extra:
problems.append(f"unregistered kinds in _RESPONSE_MAP: {sorted(extra)}")
raise RuntimeError(
"_RESPONSE_MAP is out of sync with storage._SOURCE_TYPE_MAP: " + "; ".join(problems)
)
_assert_response_map_coverage()
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
"""Convert a ColorStripSource to the matching per-type response schema."""
kw = _common_response_kwargs(source, overlay_active)
builder = _RESPONSE_MAP.get(type(source))
builder = _RESPONSE_MAP.get(source.source_type)
if builder is None:
# Fallback: use to_dict() and build a PictureCSSResponse
logger.warning("No response builder for %s, falling back", type(source).__name__)
return PictureCSSResponse(
**kw,
picture_source_id="",
smoothing=0.3,
interpolation_mode="average",
calibration=None,
# Coverage is asserted at import time, so reaching this branch means a
# source was loaded with a source_type that is not registered.
# Surface the bug instead of silently returning a wrong-shaped response.
raise RuntimeError(
f"No CSS response builder registered for source_type "
f"{source.source_type!r} (class={type(source).__name__})"
)
return builder(source, kw)
@@ -29,7 +29,7 @@ router = APIRouter()
_PREVIEW_ALLOWED_TYPES = {
"static",
"single_color",
"gradient",
"effect",
"daylight",
@@ -97,65 +97,65 @@ async def preview_color_strip_ws(
return ColorStripSource.from_dict(config)
def _create_stream(source):
"""Instantiate and start the appropriate stream class for *source*."""
from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
"""Instantiate and start the appropriate stream class for *source*.
Delegates the per-kind dispatch to ``color_strip_kinds.build_stream``
so this preview path and the production ``ColorStripStreamManager``
share a single registry. Per-kind dependencies (CSPT store, audio
stores, weather manager, …) are gathered into a ``StreamDeps`` bag.
FastAPI-DI providers raise ``RuntimeError`` when they aren't wired,
so we resolve each one through ``_safe`` and pass ``None`` on
failure. The per-kind builder will still see a clear error if a
truly-required dep is missing for that kind, but unrelated previews
(e.g. a ``single_color`` preview on a fresh install where the CSPT
store isn't initialized yet) keep working.
"""
from ledgrab.api.dependencies import (
get_audio_processing_template_store,
get_audio_source_store,
get_audio_template_store,
get_cspt_store,
)
from ledgrab.core.processing.color_strip_kinds import StreamDeps, build_stream
def _safe(getter):
try:
return getter()
except RuntimeError as e:
logger.debug("Preview dep not available (%s): %s", getter.__name__, e)
return None
mgr = get_processor_manager()
csm = mgr.color_strip_stream_manager
if source.source_type == "audio":
from ledgrab.api.dependencies import (
get_audio_processing_template_store,
get_audio_source_store,
get_audio_template_store,
)
from ledgrab.core.processing.audio_stream import AudioColorStripStream
# The game-event bus is optional in preview contexts.
try:
from ledgrab.api.dependencies import get_game_event_bus
s = AudioColorStripStream(
source,
mgr.audio_capture_manager,
get_audio_source_store(),
get_audio_template_store(),
get_audio_processing_template_store(),
)
elif source.source_type == "weather":
from ledgrab.core.processing.weather_stream import WeatherColorStripStream
game_event_bus = get_game_event_bus()
except RuntimeError as e:
logger.debug("Preview: no game event bus available: %s", e)
game_event_bus = None
s = WeatherColorStripStream(source, mgr.weather_manager)
elif source.source_type == "game_event":
from ledgrab.core.processing.game_event_stream import GameEventColorStripStream
s = GameEventColorStripStream(source)
try:
from ledgrab.api.dependencies import get_game_event_bus
bus = get_game_event_bus()
except RuntimeError as e:
logger.debug("Preview: no game event bus available: %s", e)
else:
if bus is not None:
s.set_event_bus(bus)
elif source.source_type == "mapped":
from ledgrab.core.processing.mapped_stream import MappedColorStripStream
s = MappedColorStripStream(source, csm)
elif source.source_type == "composite":
from ledgrab.api.dependencies import get_cspt_store
from ledgrab.core.processing.composite_stream import CompositeColorStripStream
s = CompositeColorStripStream(
source, csm, mgr.value_stream_manager, get_cspt_store(), depth=0
)
elif source.source_type == "processed":
from ledgrab.api.dependencies import get_cspt_store
from ledgrab.core.processing.processed_stream import ProcessedColorStripStream
s = ProcessedColorStripStream(source, csm, get_cspt_store())
else:
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls:
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
s = stream_cls(source)
deps = StreamDeps(
css_manager=csm,
value_stream_manager=mgr.value_stream_manager,
cspt_store=_safe(get_cspt_store),
weather_manager=mgr.weather_manager,
audio_capture_manager=mgr.audio_capture_manager,
audio_source_store=_safe(get_audio_source_store),
audio_template_store=_safe(get_audio_template_store),
audio_processing_template_store=_safe(get_audio_processing_template_store),
game_event_bus=game_event_bus,
depth=0,
)
try:
s = build_stream(source, deps)
except ValueError as e:
# Preserve the registry's original detail so the API consumer
# sees which kind was rejected, not just a generic message.
raise ValueError(f"Unsupported preview source_type: {e}") from e
# Inject gradient store for palette resolution
if hasattr(s, "set_gradient_store"):
try:
@@ -428,8 +428,17 @@ async def css_api_input_ws(
continue
elif "bytes" in message:
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
# Binary frame: raw RGBRGB... bytes (3 bytes per LED).
# Cap to a generous upper bound on the LED count — a hostile
# client could otherwise stream 100 MB frames and OOM the
# server before any application logic ran.
raw_bytes = message["bytes"]
_MAX_BINARY_LEDS = 8192
if len(raw_bytes) > _MAX_BINARY_LEDS * 3:
await websocket.send_json(
{"error": f"Binary frame too large (max {_MAX_BINARY_LEDS} LEDs)"}
)
continue
if len(raw_bytes) % 3 != 0:
await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"})
continue
+9 -2
View File
@@ -640,11 +640,18 @@ async def update_device(
icon_color=update_data.icon_color,
)
# Sync connection info in processor manager
# Sync connection info in processor manager.
#
# When a PATCH omits `url` (rename / icon-only edit) `normalized_url`
# is None — fall back to the existing record's URL so the processor
# is always told the current address, otherwise it silently keeps
# whatever it had cached (or worse, treats None as "unconfigured"
# and refuses to re-sync).
effective_url = normalized_url if normalized_url is not None else existing.url
try:
manager.update_device_info(
device_id,
device_url=normalized_url,
device_url=effective_url,
led_count=normalized_led_count,
baud_rate=update_data.baud_rate,
)
@@ -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,
)
+50 -19
View File
@@ -1,7 +1,7 @@
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
from typing import Annotated, Optional
from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, Depends
@@ -175,26 +175,57 @@ def _validate_color_value_source(
)
def _target_to_response(target) -> OutputTargetResponse:
"""Convert any OutputTarget to the appropriate typed response."""
if isinstance(target, WledOutputTarget):
return _led_target_to_response(target)
elif isinstance(target, HALightOutputTarget):
return _ha_light_target_to_response(target)
elif isinstance(target, Z2MLightOutputTarget):
return _z2m_light_target_to_response(target)
else:
# Fallback for unknown types — use LED response with defaults
return LedOutputTargetResponse(
id=target.id,
name=target.name,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
_TARGET_RESPONSE_BUILDERS: dict = {
WledOutputTarget: _led_target_to_response,
HALightOutputTarget: _ha_light_target_to_response,
Z2MLightOutputTarget: _z2m_light_target_to_response,
}
def _assert_target_response_coverage() -> None:
"""Verify the response registry covers every concrete OutputTarget subclass.
Runs at module import. Surfaces a missing builder eagerly instead of
letting a request fall through to the previous silent fallback (which
used to return a defaults-filled LedOutputTargetResponse and quietly
misshape the payload for unknown target types).
"""
expected = {WledOutputTarget, HALightOutputTarget, Z2MLightOutputTarget}
registered = set(_TARGET_RESPONSE_BUILDERS.keys())
missing = expected - registered
extra = registered - expected
if missing or extra:
problems = []
if missing:
problems.append(f"missing builders: {sorted(c.__name__ for c in missing)}")
if extra:
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
raise RuntimeError(
"_TARGET_RESPONSE_BUILDERS is out of sync with the OutputTarget "
"subclass set: " + "; ".join(problems)
)
_assert_target_response_coverage()
def _target_to_response(target) -> OutputTargetResponse:
"""Convert any OutputTarget to the appropriate typed response.
Dispatches via :data:`_TARGET_RESPONSE_BUILDERS` keyed by concrete
subclass. Raises ``RuntimeError`` for an unregistered subclass —
coverage is asserted at import, so this should never fire in
practice; if it does, the storage layer added a new OutputTarget
subclass without a matching response builder here.
"""
builder = _TARGET_RESPONSE_BUILDERS.get(type(target))
if builder is None:
raise RuntimeError(
f"No response builder registered for OutputTarget subclass " f"{type(target).__name__}"
)
return builder(target)
# ===== CRUD ENDPOINTS =====
@@ -390,7 +421,7 @@ async def get_target(
def _resolve_effective_color_vs_id(
target_store: OutputTargetStore, target_id: str, payload_id: Optional[str]
target_store: OutputTargetStore, target_id: str, payload_id: str | None
) -> str:
if payload_id is not None:
return payload_id
+1 -2
View File
@@ -9,7 +9,6 @@ import subprocess
import sys
import time
from datetime import datetime, timezone
from typing import Optional
import os
@@ -190,7 +189,7 @@ async def list_all_tags(_: AuthRequired):
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
async def get_displays(
_: AuthRequired,
engine_type: Optional[str] = Query(None, description="Engine type to get displays for"),
engine_type: str | None = Query(None, description="Engine type to get displays for"),
):
"""Get list of available displays.
+20 -2
View File
@@ -1,7 +1,7 @@
"""Value source routes: CRUD for value sources."""
import asyncio
from typing import Annotated, Optional
from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
@@ -23,6 +23,7 @@ from ledgrab.api.schemas.value_sources import (
DaylightValueSourceResponse,
GradientMapValueSourceResponse,
HAEntityValueSourceResponse,
HTTPValueSourceResponse,
StaticColorValueSourceResponse,
StaticValueSourceResponse,
SystemMetricsValueSourceResponse,
@@ -41,6 +42,7 @@ from ledgrab.storage.value_source import (
DaylightValueSource,
GradientMapValueSource,
HAEntityValueSource,
HTTPValueSource,
StaticColorValueSource,
StaticValueSource,
SystemMetricsValueSource,
@@ -213,6 +215,22 @@ _RESPONSE_MAP = {
poll_interval=s.poll_interval,
smoothing=s.smoothing,
),
HTTPValueSource: lambda s: HTTPValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
http_endpoint_id=s.http_endpoint_id,
json_path=s.json_path,
interval_s=s.interval_s,
min_value=s.min_value,
max_value=s.max_value,
smoothing=s.smoothing,
),
}
@@ -271,7 +289,7 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
async def list_value_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(
source_type: str | None = Query(
None,
description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene",
),
+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):
+9 -9
View File
@@ -1,7 +1,7 @@
"""Asset schemas (CRUD)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -9,15 +9,15 @@ from pydantic import BaseModel, Field
class AssetUpdate(BaseModel):
"""Request to update asset metadata."""
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(
name: str | None = Field(None, min_length=1, max_length=100, description="Display name")
description: str | None = Field(None, max_length=500, description="Optional description")
tags: List[str] | None = Field(None, description="User-defined tags")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -33,15 +33,15 @@ class AssetResponse(BaseModel):
mime_type: str = Field(description="MIME type")
asset_type: str = Field(description="Asset type: sound, image, video, other")
size_bytes: int = Field(description="File size in bytes")
description: Optional[str] = Field(None, description="Description")
description: str | None = 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(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Audio processing template schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class AudioProcessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of audio filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class AudioProcessingTemplateCreate(BaseModel):
class AudioProcessingTemplateUpdate(BaseModel):
"""Request to update an audio processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of audio filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -61,13 +61,13 @@ class AudioProcessingTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+25 -31
View File
@@ -1,7 +1,7 @@
"""Audio source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union
from typing import Annotated, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag
@@ -15,16 +15,16 @@ class _AudioSourceResponseBase(BaseModel):
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
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(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -35,7 +35,7 @@ class CaptureAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["capture"] = "capture"
device_index: int = Field(description="Audio device index (-1 = default)")
is_loopback: bool = Field(description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
@@ -45,10 +45,8 @@ class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
AudioSourceResponse = Annotated[
Union[
Annotated[CaptureAudioSourceResponse, Tag("capture")],
Annotated[ProcessedAudioSourceResponse, Tag("processed")],
],
Annotated[CaptureAudioSourceResponse, Tag("capture")]
| Annotated[ProcessedAudioSourceResponse, Tag("processed")],
Discriminator("source_type"),
]
@@ -61,14 +59,14 @@ class _AudioSourceCreateBase(BaseModel):
"""Shared fields for all audio source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -79,7 +77,7 @@ class CaptureAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["capture"] = "capture"
device_index: int = Field(-1, description="Audio device index (-1 = default)")
is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
@@ -89,10 +87,8 @@ class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
AudioSourceCreate = Annotated[
Union[
Annotated[CaptureAudioSourceCreate, Tag("capture")],
Annotated[ProcessedAudioSourceCreate, Tag("processed")],
],
Annotated[CaptureAudioSourceCreate, Tag("capture")]
| Annotated[ProcessedAudioSourceCreate, Tag("processed")],
Discriminator("source_type"),
]
@@ -104,15 +100,15 @@ AudioSourceCreate = Annotated[
class _AudioSourceUpdateBase(BaseModel):
"""Shared fields for all audio source update requests."""
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(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -121,24 +117,22 @@ class _AudioSourceUpdateBase(BaseModel):
class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["capture"] = "capture"
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
device_index: int | None = Field(None, description="Audio device index (-1 = default)")
is_loopback: bool | None = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["processed"] = "processed"
audio_source_id: Optional[str] = Field(None, description="Input audio source ID")
audio_processing_template_id: Optional[str] = Field(
audio_source_id: str | None = Field(None, description="Input audio source ID")
audio_processing_template_id: str | None = Field(
None, description="Audio processing template ID"
)
AudioSourceUpdate = Annotated[
Union[
Annotated[CaptureAudioSourceUpdate, Tag("capture")],
Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
],
Annotated[CaptureAudioSourceUpdate, Tag("capture")]
| Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
Discriminator("source_type"),
]
@@ -1,7 +1,7 @@
"""Audio capture template and engine schemas."""
from datetime import datetime
from typing import Dict, List, Optional
from typing import Dict, List
from pydantic import BaseModel, Field
@@ -14,14 +14,14 @@ class AudioTemplateCreate(BaseModel):
description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1
)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -31,17 +31,17 @@ class AudioTemplateCreate(BaseModel):
class AudioTemplateUpdate(BaseModel):
"""Request to update an audio template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: Optional[str] = Field(None, description="Audio engine type")
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(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: str | None = Field(None, description="Audio engine type")
engine_config: Dict | None = Field(None, description="Engine-specific configuration")
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -58,13 +58,13 @@ class AudioTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+54 -42
View File
@@ -1,7 +1,7 @@
"""Automation-related schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -11,41 +11,55 @@ class RuleSchema(BaseModel):
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
# Application rule fields
apps: Optional[List[str]] = Field(None, description="Process names (for application rule)")
match_type: Optional[str] = Field(
apps: List[str] | None = Field(None, description="Process names (for application rule)")
match_type: str | None = Field(
None, description="'running' or 'topmost' (for application rule)"
)
# Time-of-day rule fields
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day rule)")
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day rule)")
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
# System idle rule fields
idle_minutes: Optional[int] = Field(
idle_minutes: int | None = Field(
None, description="Idle timeout in minutes (for system_idle rule)"
)
when_idle: Optional[bool] = Field(
None, description="True=active when idle (for system_idle rule)"
)
when_idle: bool | None = Field(None, description="True=active when idle (for system_idle rule)")
# Display state rule fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)")
state: str | None = Field(None, description="'on' or 'off' (for display_state rule)")
# MQTT rule fields
mqtt_source_id: Optional[str] = Field(None, description="MQTT source ID (for mqtt rule)")
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt rule)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)")
match_mode: Optional[str] = Field(
mqtt_source_id: str | None = Field(None, description="MQTT source ID (for mqtt rule)")
topic: str | None = Field(None, description="MQTT topic to watch (for mqtt rule)")
payload: str | None = Field(None, description="Expected payload value (for mqtt rule)")
match_mode: str | None = Field(
None, description="'exact', 'contains', or 'regex' (for mqtt rule)"
)
# Webhook rule fields
token: Optional[str] = Field(
None, description="Secret token for webhook URL (for webhook rule)"
)
token: str | None = Field(None, description="Secret token for webhook URL (for webhook rule)")
# Home Assistant rule fields
ha_source_id: Optional[str] = Field(
ha_source_id: str | None = Field(
None, description="Home Assistant source ID (for home_assistant rule)"
)
entity_id: Optional[str] = Field(
entity_id: str | None = Field(
None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
)
# HTTP poll rule fields
value_source_id: str | None = Field(
None,
description=(
"Value source ID (for http_poll rule). The referenced "
"ValueSource must be of source_type='http'."
),
)
operator: str | None = Field(
None,
description=(
"Comparison operator for http_poll rule: "
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
),
)
value: str | None = Field(
None, description="Expected value (for http_poll rule; ignored for 'exists')"
)
# Backward-compatible alias
@@ -59,20 +73,20 @@ class AutomationCreate(BaseModel):
enabled: bool = Field(default=True, description="Whether the automation is enabled")
rule_logic: str = Field(default="or", description="How rules combine: 'or' or 'and'")
rules: List[RuleSchema] = Field(default_factory=list, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(
default="none", description="'none', 'revert', or 'fallback_scene'"
)
deactivation_scene_preset_id: Optional[str] = Field(
deactivation_scene_preset_id: str | None = Field(
None, description="Scene preset for fallback deactivation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -82,24 +96,22 @@ class AutomationCreate(BaseModel):
class AutomationUpdate(BaseModel):
"""Request to update an automation."""
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
rule_logic: Optional[str] = Field(None, description="How rules combine: 'or' or 'and'")
rules: Optional[List[RuleSchema]] = Field(None, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: Optional[str] = Field(
None, description="'none', 'revert', or 'fallback_scene'"
)
deactivation_scene_preset_id: Optional[str] = Field(
name: str | None = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: bool | None = Field(None, description="Whether the automation is enabled")
rule_logic: str | None = Field(None, description="How rules combine: 'or' or 'and'")
rules: List[RuleSchema] | None = Field(None, description="List of rules")
scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str | None = Field(None, description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: str | None = Field(
None, description="Scene preset for fallback deactivation"
)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -114,26 +126,26 @@ class AutomationResponse(BaseModel):
enabled: bool = Field(description="Whether the automation is enabled")
rule_logic: str = Field(description="Rule combination logic")
rules: List[RuleSchema] = Field(description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
webhook_url: Optional[str] = Field(
webhook_url: str | None = Field(
None, description="Webhook URL for the first webhook rule (if any)"
)
is_active: bool = Field(default=False, description="Whether the automation is currently active")
last_activated_at: Optional[datetime] = Field(
last_activated_at: datetime | None = Field(
None, description="Last time this automation was activated"
)
last_deactivated_at: Optional[datetime] = Field(
last_deactivated_at: datetime | None = Field(
None, description="Last time this automation was deactivated"
)
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Color strip processing template schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class ColorStripProcessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class ColorStripProcessingTemplateCreate(BaseModel):
class ColorStripProcessingTemplateUpdate(BaseModel):
"""Request to update a color strip processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -59,13 +59,13 @@ class ColorStripProcessingTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,13 +1,12 @@
"""Color strip source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from typing import Annotated, Any, Dict, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
from ledgrab.api.schemas.devices import Calibration
# =====================================================================
# Helper models (unchanged)
# =====================================================================
@@ -16,10 +15,10 @@ from ledgrab.api.schemas.devices import Calibration
class AppSoundOverride(BaseModel):
"""Per-application sound override for notification sources."""
sound_asset_id: Optional[str] = Field(
sound_asset_id: str | None = Field(
None, description="Asset ID for the sound (None = mute this app)"
)
volume: Optional[float] = Field(
volume: float | None = Field(
None, ge=0.0, le=1.0, description="Volume override (None = use global)"
)
@@ -39,7 +38,7 @@ class ColorStop(BaseModel):
description="Relative position along the strip (0.0-1.0)", ge=0.0, le=1.0
)
color: List[int] = Field(description="Primary RGB color [R, G, B] (0-255 each)")
color_right: Optional[List[int]] = Field(
color_right: List[int] | None = Field(
None,
description="Optional right-side RGB color for a hard edge (bidirectional stop)",
)
@@ -54,10 +53,10 @@ class CompositeLayer(BaseModel):
)
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
enabled: bool = Field(default=True, description="Whether this layer is active")
brightness_source_id: Optional[str] = Field(
brightness_source_id: str | None = Field(
None, description="Optional value source ID for dynamic brightness"
)
processing_template_id: Optional[str] = Field(
processing_template_id: str | None = Field(
None, description="Optional color strip processing template ID"
)
start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)")
@@ -86,21 +85,21 @@ class _CSSResponseBase(BaseModel):
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
led_count: int = Field(0, description="Total LED count (0 = auto)")
overlay_active: bool = Field(
False, description="Whether the screen overlay is currently active"
)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
clock_id: str | None = Field(None, description="Optional sync clock ID")
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(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -112,40 +111,40 @@ class PictureCSSResponse(_CSSResponseBase):
picture_source_id: str = Field(description="Picture source ID")
smoothing: Any = Field(description="Temporal smoothing")
interpolation_mode: str = Field(description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSResponse(_CSSResponseBase):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(description="Temporal smoothing")
interpolation_mode: str = Field(description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
calibration: Calibration | None = Field(None, description="LED calibration")
class StaticCSSResponse(_CSSResponseBase):
source_type: Literal["static"] = "static"
color: Any = Field(description="Static RGB color")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
class SingleColorCSSResponse(_CSSResponseBase):
source_type: Literal["single_color"] = "single_color"
color: Any = Field(description="Solid RGB color")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSResponse(_CSSResponseBase):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: str = Field(description="Gradient interpolation easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSResponse(_CSSResponseBase):
source_type: Literal["effect"] = "effect"
effect_type: str = Field(description="Effect algorithm")
palette: str = Field(description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(description="Primary color")
intensity: Any = Field(description="Effect intensity")
scale: Any = Field(description="Spatial scale")
mirror: bool = Field(description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSResponse(_CSSResponseBase):
@@ -165,7 +164,7 @@ class AudioCSSResponse(_CSSResponseBase):
sensitivity: Any = Field(description="Audio sensitivity")
smoothing: Any = Field(description="Temporal smoothing")
palette: str = Field(description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(description="Primary color")
color_peak: Any = Field(description="Peak color")
mirror: bool = Field(description="Mirror mode")
@@ -188,7 +187,7 @@ class NotificationCSSResponse(_CSSResponseBase):
app_filter_mode: str = Field(description="App filter mode")
app_filter_list: List[str] = Field(default_factory=list, description="App names for filter")
os_listener: bool = Field(description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(description="Global notification sound volume")
app_sounds: Dict[str, dict] = Field(default_factory=dict, description="Per-app sound overrides")
@@ -237,28 +236,34 @@ class MathWaveCSSResponse(_CSSResponseBase):
source_type: Literal["math_wave"] = "math_wave"
waves: List[dict] = Field(description="Wave layer definitions")
speed: Any = Field(description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
gradient_id: str | None = 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[GradientCSSResponse, Tag("gradient")],
Annotated[EffectCSSResponse, Tag("effect")],
Annotated[CompositeCSSResponse, Tag("composite")],
Annotated[MappedCSSResponse, Tag("mapped")],
Annotated[AudioCSSResponse, Tag("audio")],
Annotated[ApiInputCSSResponse, Tag("api_input")],
Annotated[NotificationCSSResponse, Tag("notification")],
Annotated[DaylightCSSResponse, Tag("daylight")],
Annotated[CandlelightCSSResponse, Tag("candlelight")],
Annotated[ProcessedCSSResponse, Tag("processed")],
Annotated[WeatherCSSResponse, Tag("weather")],
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
Annotated[MathWaveCSSResponse, Tag("math_wave")],
],
Annotated[PictureCSSResponse, Tag("picture")]
| Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")]
| Annotated[SingleColorCSSResponse, Tag("single_color")]
| Annotated[GradientCSSResponse, Tag("gradient")]
| Annotated[EffectCSSResponse, Tag("effect")]
| Annotated[CompositeCSSResponse, Tag("composite")]
| Annotated[MappedCSSResponse, Tag("mapped")]
| Annotated[AudioCSSResponse, Tag("audio")]
| Annotated[ApiInputCSSResponse, Tag("api_input")]
| Annotated[NotificationCSSResponse, Tag("notification")]
| Annotated[DaylightCSSResponse, Tag("daylight")]
| Annotated[CandlelightCSSResponse, Tag("candlelight")]
| Annotated[ProcessedCSSResponse, Tag("processed")]
| Annotated[WeatherCSSResponse, Tag("weather")]
| Annotated[KeyColorsCSSResponse, Tag("key_colors")]
| Annotated[MathWaveCSSResponse, Tag("math_wave")]
| Annotated[GameEventCSSResponse, Tag("game_event")],
Discriminator("source_type"),
]
@@ -273,15 +278,15 @@ class _CSSCreateBase(BaseModel):
name: str = Field(description="Source name", min_length=1, max_length=100)
led_count: int = Field(default=0, description="Total LED count (0 = auto)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
description: str | None = Field(None, description="Optional description", max_length=500)
clock_id: str | None = Field(None, description="Optional sync clock ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -293,63 +298,63 @@ class PictureCSSCreate(_CSSCreateBase):
picture_source_id: str = Field(default="", description="Picture source ID")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: str = Field(default="average", description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSCreate(_CSSCreateBase):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: str = Field(default="average", description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
calibration: Calibration | None = 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]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
class SingleColorCSSCreate(_CSSCreateBase):
source_type: Literal["single_color"] = "single_color"
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSCreate(_CSSCreateBase):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: str | None = Field(None, description="Gradient easing")
gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSCreate(_CSSCreateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
effect_type: str | None = Field(None, description="Effect algorithm")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSCreate(_CSSCreateBase):
source_type: Literal["composite"] = "composite"
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
layers: List[CompositeLayer] | None = Field(None, description="Layers for composite type")
class MappedCSSCreate(_CSSCreateBase):
source_type: Literal["mapped"] = "mapped"
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
zones: List[MappedZone] | None = Field(None, description="Zones for mapped type")
class AudioCSSCreate(_CSSCreateBase):
source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
visualization_mode: str | None = Field(None, description="Audio visualization mode")
audio_source_id: str | None = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode")
mirror: bool | None = Field(None, description="Mirror mode")
beat_decay: Any = Field(
default=None, description="Beat pulse decay rate (music modes, 0.01-0.5)"
)
@@ -359,23 +364,23 @@ class ApiInputCSSCreate(_CSSCreateBase):
source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]")
timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode")
interpolation: str | None = Field(None, description="LED count interpolation mode")
class NotificationCSSCreate(_CSSCreateBase):
source_type: Literal["notification"] = "notification"
notification_effect: Optional[str] = Field(None, description="Notification effect")
notification_effect: str | None = Field(None, description="Notification effect")
duration_ms: Any = Field(default=None, description="Effect duration in milliseconds")
default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field(
default_color: List[int] | Dict[str, Any] | str | None = Field(
None, description="Default color"
)
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
app_colors: Dict[str, str] | None = Field(None, description="Per-app hex colors")
app_filter_mode: str | None = Field(None, description="App filter mode")
app_filter_list: List[str] | None = Field(None, description="App names for filter")
os_listener: bool | None = Field(None, description="Listen for OS notifications")
sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(default=None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(
app_sounds: Dict[str, AppSoundOverride] | None = Field(
None, description="Per-app sound overrides"
)
@@ -383,9 +388,9 @@ class NotificationCSSCreate(_CSSCreateBase):
class DaylightCSSCreate(_CSSCreateBase):
source_type: Literal["daylight"] = "daylight"
speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: float | None = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float | None = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
)
@@ -394,23 +399,23 @@ class CandlelightCSSCreate(_CSSCreateBase):
source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]")
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
num_candles: Optional[int] = Field(
num_candles: int | None = Field(
None, description="Number of candle sources (1-20)", ge=1, le=20
)
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
candle_type: Optional[str] = Field(None, description="Candle type preset")
candle_type: str | None = Field(None, description="Candle type preset")
class ProcessedCSSCreate(_CSSCreateBase):
source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID")
input_source_id: str | None = Field(None, description="Input color strip source ID")
processing_template_id: str | None = Field(None, description="Processing template ID")
class WeatherCSSCreate(_CSSCreateBase):
source_type: Literal["weather"] = "weather"
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
weather_source_id: str | None = Field(None, description="Weather source entity ID")
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
@@ -418,41 +423,47 @@ class WeatherCSSCreate(_CSSCreateBase):
class KeyColorsCSSCreate(_CSSCreateBase):
source_type: Literal["key_colors"] = "key_colors"
picture_source_id: str = Field(default="", description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions")
rectangles: List[dict] | None = Field(None, description="Named screen regions")
interpolation_mode: str = Field(default="average", description="Interpolation mode")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field(
brightness_value_source_id: str | None = Field(
None, description="Dynamic brightness value source ID"
)
class MathWaveCSSCreate(_CSSCreateBase):
source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions")
waves: List[dict] | None = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable, 0.1-10.0)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSCreate(_CSSCreateBase):
source_type: Literal["game_event"] = "game_event"
game_integration_id: str | None = Field(None, description="Game integration entity ID")
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
event_mappings: List[dict] | None = Field(None, description="Event-to-effect mappings")
ColorStripSourceCreate = Annotated[
Union[
Annotated[PictureCSSCreate, Tag("picture")],
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
Annotated[StaticCSSCreate, Tag("static")],
Annotated[GradientCSSCreate, Tag("gradient")],
Annotated[EffectCSSCreate, Tag("effect")],
Annotated[CompositeCSSCreate, Tag("composite")],
Annotated[MappedCSSCreate, Tag("mapped")],
Annotated[AudioCSSCreate, Tag("audio")],
Annotated[ApiInputCSSCreate, Tag("api_input")],
Annotated[NotificationCSSCreate, Tag("notification")],
Annotated[DaylightCSSCreate, Tag("daylight")],
Annotated[CandlelightCSSCreate, Tag("candlelight")],
Annotated[ProcessedCSSCreate, Tag("processed")],
Annotated[WeatherCSSCreate, Tag("weather")],
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
Annotated[MathWaveCSSCreate, Tag("math_wave")],
],
Annotated[PictureCSSCreate, Tag("picture")]
| Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")]
| Annotated[SingleColorCSSCreate, Tag("single_color")]
| Annotated[GradientCSSCreate, Tag("gradient")]
| Annotated[EffectCSSCreate, Tag("effect")]
| Annotated[CompositeCSSCreate, Tag("composite")]
| Annotated[MappedCSSCreate, Tag("mapped")]
| Annotated[AudioCSSCreate, Tag("audio")]
| Annotated[ApiInputCSSCreate, Tag("api_input")]
| Annotated[NotificationCSSCreate, Tag("notification")]
| Annotated[DaylightCSSCreate, Tag("daylight")]
| Annotated[CandlelightCSSCreate, Tag("candlelight")]
| Annotated[ProcessedCSSCreate, Tag("processed")]
| Annotated[WeatherCSSCreate, Tag("weather")]
| Annotated[KeyColorsCSSCreate, Tag("key_colors")]
| Annotated[MathWaveCSSCreate, Tag("math_wave")]
| Annotated[GameEventCSSCreate, Tag("game_event")],
Discriminator("source_type"),
]
@@ -465,17 +476,17 @@ ColorStripSourceCreate = Annotated[
class _CSSUpdateBase(BaseModel):
"""Shared fields for all color strip source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto)", ge=0)
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(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
led_count: int | None = Field(None, description="Total LED count (0 = auto)", ge=0)
description: str | None = Field(None, description="Optional description", max_length=500)
clock_id: str | None = Field(None, description="Optional sync clock ID")
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -484,66 +495,66 @@ class _CSSUpdateBase(BaseModel):
class PictureCSSUpdate(_CSSUpdateBase):
source_type: Literal["picture"] = "picture"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
picture_source_id: str | None = Field(None, description="Picture source ID")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
interpolation_mode: str | None = Field(None, description="Interpolation mode")
calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSUpdate(_CSSUpdateBase):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
interpolation_mode: str | None = Field(None, description="Interpolation mode")
calibration: Calibration | None = 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]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
class SingleColorCSSUpdate(_CSSUpdateBase):
source_type: Literal["single_color"] = "single_color"
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSUpdate(_CSSUpdateBase):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: str | None = Field(None, description="Gradient easing")
gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSUpdate(_CSSUpdateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
effect_type: str | None = Field(None, description="Effect algorithm")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSUpdate(_CSSUpdateBase):
source_type: Literal["composite"] = "composite"
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
layers: List[CompositeLayer] | None = Field(None, description="Layers for composite type")
class MappedCSSUpdate(_CSSUpdateBase):
source_type: Literal["mapped"] = "mapped"
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
zones: List[MappedZone] | None = Field(None, description="Zones for mapped type")
class AudioCSSUpdate(_CSSUpdateBase):
source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
visualization_mode: str | None = Field(None, description="Audio visualization mode")
audio_source_id: str | None = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode")
mirror: bool | None = Field(None, description="Mirror mode")
beat_decay: Any = Field(default=None, description="Beat pulse decay rate (music modes)")
@@ -551,23 +562,23 @@ class ApiInputCSSUpdate(_CSSUpdateBase):
source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]")
timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode")
interpolation: str | None = Field(None, description="LED count interpolation mode")
class NotificationCSSUpdate(_CSSUpdateBase):
source_type: Literal["notification"] = "notification"
notification_effect: Optional[str] = Field(None, description="Notification effect")
notification_effect: str | None = Field(None, description="Notification effect")
duration_ms: Any = Field(default=None, description="Effect duration in milliseconds")
default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field(
default_color: List[int] | Dict[str, Any] | str | None = Field(
None, description="Default color"
)
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
app_colors: Dict[str, str] | None = Field(None, description="Per-app hex colors")
app_filter_mode: str | None = Field(None, description="App filter mode")
app_filter_list: List[str] | None = Field(None, description="App names for filter")
os_listener: bool | None = Field(None, description="Listen for OS notifications")
sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(default=None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(
app_sounds: Dict[str, AppSoundOverride] | None = Field(
None, description="Per-app sound overrides"
)
@@ -575,9 +586,9 @@ class NotificationCSSUpdate(_CSSUpdateBase):
class DaylightCSSUpdate(_CSSUpdateBase):
source_type: Literal["daylight"] = "daylight"
speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: float | None = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float | None = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
)
@@ -586,65 +597,71 @@ class CandlelightCSSUpdate(_CSSUpdateBase):
source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]")
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
num_candles: Optional[int] = Field(
num_candles: int | None = Field(
None, description="Number of candle sources (1-20)", ge=1, le=20
)
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
candle_type: Optional[str] = Field(None, description="Candle type preset")
candle_type: str | None = Field(None, description="Candle type preset")
class ProcessedCSSUpdate(_CSSUpdateBase):
source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID")
input_source_id: str | None = Field(None, description="Input color strip source ID")
processing_template_id: str | None = Field(None, description="Processing template ID")
class WeatherCSSUpdate(_CSSUpdateBase):
source_type: Literal["weather"] = "weather"
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
weather_source_id: str | None = Field(None, description="Weather source entity ID")
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
class KeyColorsCSSUpdate(_CSSUpdateBase):
source_type: Literal["key_colors"] = "key_colors"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
picture_source_id: str | None = Field(None, description="Picture source ID")
rectangles: List[dict] | None = Field(None, description="Named screen regions")
interpolation_mode: str | None = Field(None, description="Interpolation mode")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field(
brightness_value_source_id: str | None = Field(
None, description="Dynamic brightness value source ID"
)
class MathWaveCSSUpdate(_CSSUpdateBase):
source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions")
waves: List[dict] | None = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSUpdate(_CSSUpdateBase):
source_type: Literal["game_event"] = "game_event"
game_integration_id: str | None = Field(None, description="Game integration entity ID")
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
event_mappings: List[dict] | None = Field(None, description="Event-to-effect mappings")
ColorStripSourceUpdate = Annotated[
Union[
Annotated[PictureCSSUpdate, Tag("picture")],
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
Annotated[StaticCSSUpdate, Tag("static")],
Annotated[GradientCSSUpdate, Tag("gradient")],
Annotated[EffectCSSUpdate, Tag("effect")],
Annotated[CompositeCSSUpdate, Tag("composite")],
Annotated[MappedCSSUpdate, Tag("mapped")],
Annotated[AudioCSSUpdate, Tag("audio")],
Annotated[ApiInputCSSUpdate, Tag("api_input")],
Annotated[NotificationCSSUpdate, Tag("notification")],
Annotated[DaylightCSSUpdate, Tag("daylight")],
Annotated[CandlelightCSSUpdate, Tag("candlelight")],
Annotated[ProcessedCSSUpdate, Tag("processed")],
Annotated[WeatherCSSUpdate, Tag("weather")],
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
],
Annotated[PictureCSSUpdate, Tag("picture")]
| Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")]
| Annotated[SingleColorCSSUpdate, Tag("single_color")]
| Annotated[GradientCSSUpdate, Tag("gradient")]
| Annotated[EffectCSSUpdate, Tag("effect")]
| Annotated[CompositeCSSUpdate, Tag("composite")]
| Annotated[MappedCSSUpdate, Tag("mapped")]
| Annotated[AudioCSSUpdate, Tag("audio")]
| Annotated[ApiInputCSSUpdate, Tag("api_input")]
| Annotated[NotificationCSSUpdate, Tag("notification")]
| Annotated[DaylightCSSUpdate, Tag("daylight")]
| Annotated[CandlelightCSSUpdate, Tag("candlelight")]
| Annotated[ProcessedCSSUpdate, Tag("processed")]
| Annotated[WeatherCSSUpdate, Tag("weather")]
| Annotated[KeyColorsCSSUpdate, Tag("key_colors")]
| Annotated[MathWaveCSSUpdate, Tag("math_wave")]
| Annotated[GameEventCSSUpdate, Tag("game_event")],
Discriminator("source_type"),
]
@@ -675,17 +692,17 @@ class SegmentPayload(BaseModel):
``color`` therefore fills the entire strip.
"""
start: Optional[int] = Field(
start: int | None = Field(
None, ge=0, description="Starting LED index (default 0 = beginning of strip)"
)
length: Optional[int] = Field(
length: int | None = Field(
None,
ge=1,
description="Number of LEDs in segment (default = led_count - start)",
)
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field(
color: List[int] | None = Field(None, description="RGB for solid mode [R,G,B]")
colors: List[List[int]] | None = Field(
None, description="Colors for per_pixel/gradient [[R,G,B],...]"
)
@@ -718,12 +735,10 @@ class ColorPushRequest(BaseModel):
At least one must be provided.
"""
colors: Optional[List[List[int]]] = Field(
colors: List[List[int]] | None = Field(
None, description="LED color array [[R,G,B], ...] (0-255 each)"
)
segments: Optional[List[SegmentPayload]] = Field(
None, description="Segment-based color updates"
)
segments: List[SegmentPayload] | None = Field(None, description="Segment-based color updates")
@model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest":
@@ -735,8 +750,8 @@ class ColorPushRequest(BaseModel):
class NotifyRequest(BaseModel):
"""Request to trigger a notification on a notification color strip source."""
app: Optional[str] = Field(None, description="App name for color lookup")
color: Optional[str] = Field(None, description="Hex color override (#RRGGBB)")
app: str | None = Field(None, description="App name for color lookup")
color: str | None = Field(None, description="Hex color override (#RRGGBB)")
class CSSCalibrationTestRequest(BaseModel):
+6 -6
View File
@@ -1,7 +1,7 @@
"""Shared schemas used across multiple route modules."""
from datetime import datetime
from typing import Dict, Optional
from typing import Dict
from pydantic import BaseModel, Field
@@ -11,7 +11,7 @@ class ErrorResponse(BaseModel):
error: str = Field(description="Error type")
message: str = Field(description="Error message")
detail: Optional[Dict] = Field(None, description="Additional error details")
detail: Dict | None = Field(None, description="Additional error details")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp")
@@ -19,11 +19,11 @@ class CaptureImage(BaseModel):
"""Captured image with metadata."""
image: str = Field(description="Base64-encoded thumbnail image data")
full_image: Optional[str] = Field(None, description="Base64-encoded full-resolution image data")
full_image: str | None = Field(None, description="Base64-encoded full-resolution image data")
width: int = Field(description="Original image width in pixels")
height: int = Field(description="Original image height in pixels")
thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)")
thumbnail_width: int | None = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: int | None = Field(None, description="Thumbnail height (if resized)")
class BorderExtraction(BaseModel):
@@ -48,7 +48,7 @@ class TemplateTestResponse(BaseModel):
"""Response from template test."""
full_capture: CaptureImage = Field(description="Full screen capture with thumbnail")
border_extraction: Optional[BorderExtraction] = Field(
border_extraction: BorderExtraction | None = Field(
None, description="Extracted border images (deprecated)"
)
performance: PerformanceMetrics = Field(description="Performance metrics")
+90 -100
View File
@@ -1,7 +1,7 @@
"""Device-related schemas (CRUD, calibration, device state)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from typing import Dict, List, Literal
from pydantic import BaseModel, Field
@@ -10,149 +10,145 @@ class DeviceCreate(BaseModel):
"""Request to create/attach an LED device."""
name: str = Field(description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(
url: str | None = Field(
None,
description="Device URL (e.g., http://192.168.1.100 or COM3). Not required for group devices.",
)
device_type: str = Field(default="wled", description="LED device type (e.g., wled, adalight)")
led_count: Optional[int] = Field(
led_count: int | None = Field(
None, ge=1, le=10000, description="Number of LEDs (required for adalight)"
)
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(
baud_rate: int | None = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: bool | None = Field(
default=None,
description="Turn off device when server stops (defaults to true for adalight)",
)
send_latency_ms: Optional[int] = Field(
send_latency_ms: int | None = Field(
None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)"
)
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
rgbw: bool | None = Field(None, description="RGBW mode (mock devices)")
zone_mode: str | None = Field(None, description="OpenRGB zone mode: combined or separate")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
# DMX (Art-Net / sACN) fields
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(
None, ge=0, le=32767, description="DMX start universe"
)
dmx_start_channel: Optional[int] = Field(
dmx_protocol: str | None = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: int | None = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: int | None = Field(
None, ge=1, le=512, description="DMX start channel (1-512)"
)
# DDP fields
ddp_port: Optional[int] = Field(
ddp_port: int | None = Field(
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
)
ddp_destination_id: Optional[int] = Field(
ddp_destination_id: int | None = Field(
None, ge=0, le=255, description="DDP destination ID (default 1 = display)"
)
ddp_color_order: Optional[int] = Field(
ddp_color_order: int | None = 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(
espnow_peer_mac: str | None = Field(
None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)"
)
espnow_channel: Optional[int] = Field(
None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)"
)
espnow_channel: int | None = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)")
# Philips Hue fields
hue_username: Optional[str] = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: Optional[str] = Field(
hue_username: str | None = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: str | None = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: str | None = Field(
None, description="Hue entertainment group/zone ID"
)
# Yeelight fields
yeelight_min_interval_ms: Optional[int] = Field(
yeelight_min_interval_ms: int | None = 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(
wiz_min_interval_ms: int | None = 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(
lifx_min_interval_ms: int | None = 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(
govee_min_interval_ms: int | None = 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(
opc_channel: int | None = Field(
None,
ge=0,
le=255,
description="OPC channel (0 = broadcast to all channels on the server)",
)
# Nanoleaf fields
nanoleaf_token: Optional[str] = Field(
nanoleaf_token: str | None = Field(
None,
max_length=512,
description="Nanoleaf auth token returned by the pairing handshake",
)
nanoleaf_min_interval_ms: Optional[int] = Field(
nanoleaf_min_interval_ms: int | None = 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(
spi_speed_hz: int | None = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
)
spi_led_type: Optional[str] = Field(
spi_led_type: str | None = Field(
None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW"
)
# Razer Chroma fields
chroma_device_type: Optional[str] = Field(
chroma_device_type: str | None = Field(
None,
description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad",
)
# SteelSeries GameSense fields
gamesense_device_type: Optional[str] = Field(
gamesense_device_type: str | None = Field(
None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator"
)
# BLE controller fields
ble_family: Optional[str] = Field(
ble_family: str | None = Field(
None,
description="BLE protocol family: sp110e, triones, zengge, govee",
)
ble_govee_key: Optional[str] = Field(
ble_govee_key: str | None = Field(
None,
description="Govee AES key (hex) — required for encrypted Govee firmware",
)
default_css_processing_template_id: Optional[str] = Field(
default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID"
)
# Group device fields
group_device_ids: Optional[List[str]] = Field(
group_device_ids: List[str] | None = Field(
None, description="Ordered list of child device IDs (for group device type)"
)
group_mode: Optional[str] = Field(
group_mode: str | None = Field(
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(
icon: str | None = 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(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the card's channel accent.",
@@ -162,86 +158,80 @@ class DeviceCreate(BaseModel):
class DeviceUpdate(BaseModel):
"""Request to update device information."""
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="Device URL or serial port")
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
led_count: Optional[int] = Field(
name: str | None = Field(None, description="Device name", min_length=1, max_length=100)
url: str | None = Field(None, description="Device URL or serial port")
enabled: bool | None = Field(None, description="Whether device is enabled")
led_count: int | None = Field(
None,
ge=1,
le=10000,
description="Number of LEDs (for devices with manual_led_count capability)",
)
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops")
send_latency_ms: Optional[int] = Field(
baud_rate: int | None = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: bool | None = Field(None, description="Turn off device when server stops")
send_latency_ms: int | None = Field(
None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)"
)
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
tags: Optional[List[str]] = None
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(
None, ge=0, le=32767, description="DMX start universe"
)
dmx_start_channel: Optional[int] = Field(
rgbw: bool | None = Field(None, description="RGBW mode (mock devices)")
zone_mode: str | None = Field(None, description="OpenRGB zone mode: combined or separate")
tags: List[str] | None = None
dmx_protocol: str | None = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: int | None = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: int | None = Field(
None, ge=1, le=512, description="DMX start channel (1-512)"
)
ddp_port: Optional[int] = Field(
ddp_port: int | None = 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")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: Optional[str] = Field(
None, description="Hue entertainment group ID"
)
yeelight_min_interval_ms: Optional[int] = Field(
ddp_destination_id: int | None = Field(None, ge=0, le=255, description="DDP destination ID")
ddp_color_order: int | None = Field(None, ge=0, le=5, description="DDP color order code")
espnow_peer_mac: str | None = Field(None, description="ESP-NOW peer MAC address")
espnow_channel: int | None = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
hue_username: str | None = Field(None, description="Hue bridge username")
hue_client_key: str | None = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
yeelight_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
)
wiz_min_interval_ms: Optional[int] = Field(
wiz_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="WiZ client-side rate limit in ms"
)
lifx_min_interval_ms: Optional[int] = Field(
lifx_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
)
govee_min_interval_ms: Optional[int] = Field(
govee_min_interval_ms: int | None = 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(
opc_channel: int | None = Field(None, ge=0, le=255, description="OPC channel (0 = broadcast)")
nanoleaf_token: str | None = Field(None, max_length=512, description="Nanoleaf auth token")
nanoleaf_min_interval_ms: int | None = 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")
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
ble_family: Optional[str] = Field(
spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: str | None = Field(None, description="LED chipset type")
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
gamesense_device_type: str | None = Field(None, description="GameSense device type")
ble_family: str | None = Field(
None, description="BLE protocol family: sp110e, triones, zengge, govee"
)
ble_govee_key: Optional[str] = Field(
ble_govee_key: str | None = Field(
None, description="Govee AES key (hex) — required for encrypted Govee firmware"
)
default_css_processing_template_id: Optional[str] = Field(
default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID"
)
# Group device fields
group_device_ids: Optional[List[str]] = Field(
group_device_ids: List[str] | None = Field(
None, description="Ordered list of child device IDs (for group device type)"
)
group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent")
group_mode: str | None = Field(None, description="Group mode: sequence or independent")
# Custom card icon
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -294,7 +284,7 @@ class Calibration(BaseModel):
description="Calibration mode: simple (4-edge) or advanced (multi-source lines)",
)
# Advanced mode: ordered list of lines
lines: Optional[List[CalibrationLineSchema]] = Field(
lines: List[CalibrationLineSchema] | None = Field(
default=None, description="Line list for advanced mode (ignored in simple mode)"
)
# Simple mode fields
@@ -388,7 +378,7 @@ class DeviceResponse(BaseModel):
device_type: str = Field(default="wled", description="LED device type")
led_count: int = Field(description="Total number of LEDs")
enabled: bool = Field(description="Whether device is enabled")
baud_rate: Optional[int] = Field(None, description="Serial baud rate")
baud_rate: int | None = Field(None, description="Serial baud rate")
auto_shutdown: bool = Field(
default=False, description="Restore device to idle state when targets stop"
)
@@ -473,19 +463,19 @@ class DeviceStateResponse(BaseModel):
device_id: str = Field(description="Device ID")
device_type: str = Field(default="wled", description="LED device type")
device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(
device_latency_ms: float | None = Field(None, description="Health check latency in ms")
device_name: str | None = Field(None, description="Device name reported by firmware")
device_version: str | None = Field(None, description="Firmware version")
device_led_count: int | None = Field(None, description="LED count reported by device")
device_rgbw: bool | None = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: str | None = Field(
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
)
device_fps: Optional[int] = Field(
device_fps: int | None = Field(
None, description="Device-reported FPS (WLED internal refresh rate)"
)
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
device_last_checked: datetime | None = Field(None, description="Last health check time")
device_error: str | None = Field(None, description="Last health check error")
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
test_mode_edges: List[str] = Field(
default_factory=list, description="Currently lit edges in test mode"
@@ -500,9 +490,9 @@ class DiscoveredDeviceResponse(BaseModel):
device_type: str = Field(default="wled", description="Device type")
ip: str = Field(description="IP address")
mac: str = Field(default="", description="MAC address")
led_count: Optional[int] = Field(None, description="LED count (if reachable)")
version: Optional[str] = Field(None, description="Firmware version")
ble_family: Optional[str] = Field(
led_count: int | None = Field(None, description="LED count (if reachable)")
version: str | None = Field(None, description="Firmware version")
ble_family: str | None = Field(
None, description="Detected BLE protocol family (sp110e/triones/zengge/govee)"
)
already_added: bool = Field(
+3 -3
View File
@@ -1,6 +1,6 @@
"""Filter-related schemas."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from pydantic import BaseModel, Field
@@ -22,10 +22,10 @@ class FilterOptionDefSchema(BaseModel):
min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value")
step: Any = Field(description="Step increment")
choices: Optional[List[Dict[str, str]]] = Field(
choices: List[Dict[str, str]] | None = Field(
default=None, description="Available choices for select type"
)
max_length: Optional[int] = Field(
max_length: int | None = Field(
default=None, description="Maximum string length for string type"
)
@@ -1,11 +1,10 @@
"""Pydantic schemas for game integration API endpoints."""
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from pydantic import BaseModel, Field
# ── Event Mapping ──────────────────────────────────────────────────────────
@@ -40,14 +39,14 @@ class GameIntegrationCreate(BaseModel):
event_mappings: List[EventMappingSchema] = Field(
default_factory=list, description="Event-to-effect mappings"
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
description: str | None = Field(None, description="Integration description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -57,21 +56,21 @@ class GameIntegrationCreate(BaseModel):
class GameIntegrationUpdate(BaseModel):
"""Request to update a game integration config."""
name: Optional[str] = Field(None, description="Integration name", min_length=1, max_length=100)
adapter_type: Optional[str] = Field(None, description="Adapter type identifier", min_length=1)
enabled: Optional[bool] = Field(None, description="Whether integration is active")
adapter_config: Optional[Dict[str, Any]] = Field(None, description="Adapter-specific settings")
event_mappings: Optional[List[EventMappingSchema]] = Field(
name: str | None = Field(None, description="Integration name", min_length=1, max_length=100)
adapter_type: str | None = Field(None, description="Adapter type identifier", min_length=1)
enabled: bool | None = Field(None, description="Whether integration is active")
adapter_config: Dict[str, Any] | None = Field(None, description="Adapter-specific settings")
event_mappings: List[EventMappingSchema] | None = Field(
None, description="Event-to-effect mappings"
)
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(
description: str | None = Field(None, description="Integration description", max_length=500)
tags: List[str] | None = Field(None, description="User-defined tags")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -89,14 +88,14 @@ class GameIntegrationResponse(BaseModel):
event_mappings: List[EventMappingSchema] = Field(description="Event-to-effect mappings")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Integration description")
description: str | None = Field(None, description="Integration description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
@@ -158,7 +157,7 @@ class GameIntegrationStatusResponse(BaseModel):
integration_id: str = Field(description="Integration ID")
enabled: bool = Field(description="Whether integration is active")
connected: bool = Field(description="Whether adapter is currently receiving data")
last_event_time: Optional[float] = Field(None, description="Monotonic timestamp of last event")
last_event_time: float | None = Field(None, description="Monotonic timestamp of last event")
event_count: int = Field(default=0, description="Total events received")
event_counts_by_type: Dict[str, int] = Field(
default_factory=dict, description="Event counts per event type"
+13 -13
View File
@@ -1,7 +1,7 @@
"""Gradient schemas (CRUD)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -18,14 +18,14 @@ class GradientCreate(BaseModel):
name: str = Field(description="Gradient name", min_length=1, max_length=100)
stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -35,16 +35,16 @@ class GradientCreate(BaseModel):
class GradientUpdate(BaseModel):
"""Request to update a gradient."""
name: Optional[str] = Field(None, description="Gradient name", min_length=1, max_length=100)
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(
name: str | None = Field(None, description="Gradient name", min_length=1, max_length=100)
stops: List[GradientStopSchema] | None = Field(None, description="Color stops", min_length=2)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -58,16 +58,16 @@ class GradientResponse(BaseModel):
name: str = Field(description="Gradient name")
stops: List[GradientStopSchema] = Field(description="Color stops")
is_builtin: bool = Field(description="Whether this is a built-in gradient")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
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(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Home Assistant source schemas (CRUD + test + entities)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -16,14 +16,14 @@ class HomeAssistantSourceCreate(BaseModel):
entity_filters: List[str] = Field(
default_factory=list, description="Entity ID filter patterns (e.g. ['sensor.*'])"
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -33,19 +33,19 @@ class HomeAssistantSourceCreate(BaseModel):
class HomeAssistantSourceUpdate(BaseModel):
"""Request to update a Home Assistant source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
host: Optional[str] = Field(None, description="HA host:port", min_length=1)
token: Optional[str] = Field(None, description="Long-Lived Access Token", min_length=1)
use_ssl: Optional[bool] = Field(None, description="Use wss://")
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(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
host: str | None = Field(None, description="HA host:port", min_length=1)
token: str | None = Field(None, description="Long-Lived Access Token", min_length=1)
use_ssl: bool | None = Field(None, description="Use wss://")
entity_filters: List[str] | None = Field(None, description="Entity ID filter patterns")
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -62,21 +62,21 @@ class HomeAssistantSourceResponse(BaseModel):
entity_filters: List[str] = Field(default_factory=list, description="Entity filter patterns")
connected: bool = Field(default=False, description="Whether the WebSocket connection is active")
entity_count: int = Field(default=0, description="Number of cached entities")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = 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(
token: str | None = Field(
None,
description=(
"Long-Lived Access Token. Redacted as '***' unless the request "
@@ -112,9 +112,9 @@ class HomeAssistantTestResponse(BaseModel):
"""Connection test result."""
success: bool = Field(description="Whether connection and auth succeeded")
ha_version: Optional[str] = Field(None, description="Home Assistant version")
ha_version: str | None = Field(None, description="Home Assistant version")
entity_count: int = Field(default=0, description="Number of entities found")
error: Optional[str] = Field(None, description="Error message if connection failed")
error: str | None = Field(None, description="Error message if connection failed")
class HomeAssistantConnectionStatus(BaseModel):
@@ -0,0 +1,165 @@
"""HTTP endpoint schemas (CRUD + one-shot test)."""
import re
from datetime import datetime
from typing import Any, Dict, List, Literal
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: str | None = Field(None, max_length=500)
tags: List[str] = Field(default_factory=list)
icon: str | None = Field(None, max_length=64)
icon_color: str | None = 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: str | None = Field(None, min_length=1, max_length=100)
url: str | None = Field(None, min_length=1)
method: Literal["GET", "HEAD"] | None = None
auth_token: str | None = Field(None, description="null = keep existing; '' = clear.")
headers: Dict[str, str] | None = None
timeout_s: float | None = Field(None, gt=0)
description: str | None = Field(None, max_length=500)
tags: List[str] | None = None
icon: str | None = Field(None, max_length=64)
icon_color: str | None = 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: str | None = None
tags: List[str] = Field(default_factory=list)
icon: str | None = Field(None, max_length=64)
icon_color: str | None = 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: int | None = None
body_preview: str | None = Field(None, description="First 500 chars of the body")
body_json: Any = None
error: str | None = None
+19 -19
View File
@@ -1,7 +1,7 @@
"""MQTT source schemas (CRUD + test + status)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -16,14 +16,14 @@ class MQTTSourceCreate(BaseModel):
password: str = Field(default="", description="Broker password (optional)")
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -33,21 +33,21 @@ class MQTTSourceCreate(BaseModel):
class MQTTSourceUpdate(BaseModel):
"""Request to update an MQTT source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
broker_host: Optional[str] = Field(None, description="MQTT broker hostname or IP", min_length=1)
broker_port: Optional[int] = Field(None, description="MQTT broker port", ge=1, le=65535)
username: Optional[str] = Field(None, description="Broker username")
password: Optional[str] = Field(None, description="Broker password")
client_id: Optional[str] = Field(None, description="MQTT client ID")
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(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
broker_host: str | None = Field(None, description="MQTT broker hostname or IP", min_length=1)
broker_port: int | None = Field(None, description="MQTT broker port", ge=1, le=65535)
username: str | None = Field(None, description="Broker username")
password: str | None = Field(None, description="Broker password")
client_id: str | None = Field(None, description="MQTT client ID")
base_topic: str | None = Field(None, description="Base topic prefix")
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -66,14 +66,14 @@ class MQTTSourceResponse(BaseModel):
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
connected: bool = Field(default=False, description="Whether the broker connection is active")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
@@ -93,7 +93,7 @@ class MQTTTestResponse(BaseModel):
"""Connection test result."""
success: bool = Field(description="Whether broker connection succeeded")
error: Optional[str] = Field(None, description="Error message if connection failed")
error: str | None = Field(None, description="Error message if connection failed")
class MQTTConnectionStatus(BaseModel):
+118 -138
View File
@@ -1,7 +1,7 @@
"""Output target schemas — discriminated unions per target type."""
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from typing import Annotated, Any, Dict, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag
@@ -11,7 +11,7 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
# BindableFloat — accepts plain number OR {value, source_id} dict
# ---------------------------------------------------------------------------
BindableFloatInput = Union[float, int, Dict[str, Any]]
BindableFloatInput = float | int | Dict[str, Any]
"""API input type: a plain number (static) or {"value": float, "source_id": str}."""
@@ -38,7 +38,7 @@ class HALightMappingSchema(BaseModel):
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
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(
brightness_scale: BindableFloatInput | None = Field(
default=1.0, description="Brightness multiplier (bindable)"
)
@@ -52,7 +52,7 @@ class Z2MLightMappingSchema(BaseModel):
)
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(
brightness_scale: BindableFloatInput | None = Field(
default=1.0, description="Brightness multiplier (bindable)"
)
@@ -67,7 +67,7 @@ class _OutputTargetResponseBase(BaseModel):
id: str = Field(description="Target ID")
name: str = Field(description="Target name")
description: Optional[str] = Field(None, description="Description")
description: str | None = 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")
@@ -79,13 +79,13 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
fps: BindableFloatInput | None = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: bool = Field(
@@ -110,20 +110,20 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
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(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
ha_light_mappings: List[HALightMappingSchema] | None = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Literal["none", "turn_off", "restore"] = Field(
@@ -151,24 +151,24 @@ class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
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(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
z2m_light_mappings: List[Z2MLightMappingSchema] | None = 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(
update_rate: BindableFloatInput | None = Field(
None, description="Publish rate Hz (bindable; 0.5-10)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Literal["none", "turn_off"] = Field(
@@ -179,11 +179,9 @@ class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
OutputTargetResponse = Annotated[
Union[
Annotated[LedOutputTargetResponse, Tag("led")],
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
],
Annotated[LedOutputTargetResponse, Tag("led")]
| Annotated[HALightOutputTargetResponse, Tag("ha_light")]
| Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
Discriminator("target_type"),
]
@@ -196,12 +194,12 @@ class _OutputTargetCreateBase(BaseModel):
"""Shared fields for all output target create requests."""
name: str = Field(description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None, max_length=64, description="Custom icon id from the curated icon library"
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None, max_length=32, description="Optional CSS color override for the icon"
)
@@ -210,10 +208,8 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
fps: Optional[BindableFloatInput] = Field(
brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
fps: BindableFloatInput | None = Field(
default=30, description="Target send FPS (bindable, 1-90)"
)
keepalive_interval: float = Field(
@@ -228,7 +224,7 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
ge=5,
le=600,
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
@@ -257,22 +253,20 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
ha_light_mappings: List[HALightMappingSchema] | None = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
default=2.0, description="Service call rate in Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
default=0.5, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
default=5, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
@@ -299,10 +293,8 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
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(
brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
z2m_light_mappings: List[Z2MLightMappingSchema] | None = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
)
base_topic: str = Field(
@@ -310,16 +302,16 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
max_length=128,
description="Z2M MQTT base topic prefix.",
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
default=5.0, description="Publish rate in Hz (bindable; 0.5-10)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
default=0.3, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
default=5, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
@@ -330,11 +322,9 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
OutputTargetCreate = Annotated[
Union[
Annotated[LedOutputTargetCreate, Tag("led")],
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
],
Annotated[LedOutputTargetCreate, Tag("led")]
| Annotated[HALightOutputTargetCreate, Tag("ha_light")]
| Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
Discriminator("target_type"),
]
@@ -346,15 +336,15 @@ OutputTargetCreate = Annotated[
class _OutputTargetUpdateBase(BaseModel):
"""Shared fields for all output target update requests."""
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(
name: str | None = Field(None, description="Target name", min_length=1, max_length=100)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Custom icon id; pass empty string to clear and inherit from device.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon; empty string clears.",
@@ -363,103 +353,99 @@ class _OutputTargetUpdateBase(BaseModel):
class LedOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["led"] = "led"
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable, 1-90)")
keepalive_interval: Optional[float] = Field(
device_id: str | None = Field(None, description="LED device ID")
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
fps: BindableFloatInput | None = Field(None, description="Target send FPS (bindable, 1-90)")
keepalive_interval: float | None = Field(
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
)
state_check_interval: Optional[int] = Field(
state_check_interval: int | None = Field(
None, description="Health check interval (5-600s)", ge=5, le=600
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: Optional[bool] = Field(
adaptive_fps: bool | None = Field(
None, description="Auto-reduce FPS when device is unresponsive"
)
protocol: Optional[str] = Field(
protocol: str | None = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
)
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(
ha_source_id: str | None = Field(None, description="Home Assistant source ID")
source_kind: Literal["css", "color_vs"] | None = 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(
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
color_value_source_id: str | None = 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(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
ha_light_mappings: List[HALightMappingSchema] | None = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Optional[Literal["none", "turn_off", "restore"]] = Field(
stop_action: Literal["none", "turn_off", "restore"] | None = 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(
mqtt_source_id: str | None = Field(
None,
description="MQTT source (broker) id. Empty string clears the binding.",
)
source_kind: Optional[Literal["css", "color_vs"]] = Field(
source_kind: Literal["css", "color_vs"] | None = 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(
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
color_value_source_id: str | None = 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(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
z2m_light_mappings: List[Z2MLightMappingSchema] | None = 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(
base_topic: str | None = Field(None, max_length=128, description="Z2M MQTT base topic prefix.")
update_rate: BindableFloatInput | None = Field(
None, description="Publish rate Hz (bindable; 0.5-10)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Optional[Literal["none", "turn_off"]] = Field(
stop_action: Literal["none", "turn_off"] | None = 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")],
],
Annotated[LedOutputTargetUpdate, Tag("led")]
| Annotated[HALightOutputTargetUpdate, Tag("ha_light")]
| Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")],
Discriminator("target_type"),
]
@@ -479,75 +465,69 @@ class TargetProcessingState(BaseModel):
"""Processing state for an output target."""
target_id: str = Field(description="Target ID")
device_id: Optional[str] = Field(None, description="Device ID")
device_id: str | None = Field(None, description="Device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(
fps_actual: float | None = Field(None, description="Actual FPS achieved")
fps_potential: float | None = Field(
None, description="Potential FPS (processing speed without throttle)"
)
fps_target: Optional[int] = Field(None, description="Target FPS")
fps_capture: Optional[int] = Field(
fps_target: int | None = Field(None, description="Target FPS")
fps_capture: int | None = Field(
None, description="Configured capture-side FPS for the underlying color strip stream"
)
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: Optional[int] = Field(
None, description="Keepalive frames sent during standby"
)
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
timing_extract_ms: Optional[float] = Field(
None, description="Border pixel extraction time (ms)"
)
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
timing_total_ms: Optional[float] = Field(
None, description="Total processing time per frame (ms)"
)
timing_audio_read_ms: Optional[float] = Field(None, description="Audio device read time (ms)")
timing_audio_fft_ms: Optional[float] = Field(None, description="Audio FFT analysis time (ms)")
timing_audio_render_ms: Optional[float] = Field(
frames_skipped: int | None = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: int | None = Field(None, description="Keepalive frames sent during standby")
fps_current: int | None = Field(None, description="Frames sent in the last second")
timing_send_ms: float | None = Field(None, description="DDP send time (ms)")
timing_extract_ms: float | None = Field(None, description="Border pixel extraction time (ms)")
timing_map_leds_ms: float | None = Field(None, description="LED color mapping time (ms)")
timing_smooth_ms: float | None = Field(None, description="Temporal smoothing time (ms)")
timing_total_ms: float | None = Field(None, description="Total processing time per frame (ms)")
timing_audio_read_ms: float | None = Field(None, description="Audio device read time (ms)")
timing_audio_fft_ms: float | None = Field(None, description="Audio FFT analysis time (ms)")
timing_audio_render_ms: float | None = Field(
None, description="Audio visualization render time (ms)"
)
display_index: Optional[int] = Field(None, description="Current display index")
display_index: int | None = Field(None, description="Current display index")
overlay_active: bool = Field(
default=False, description="Whether visualization overlay is active"
)
last_update: Optional[datetime] = Field(None, description="Last successful update")
last_update: datetime | None = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(
device_latency_ms: float | None = Field(None, description="Health check latency in ms")
device_name: str | None = Field(None, description="Device name reported by firmware")
device_version: str | None = Field(None, description="Firmware version")
device_led_count: int | None = Field(None, description="LED count reported by device")
device_rgbw: bool | None = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: str | None = Field(
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
)
device_fps: Optional[int] = Field(
device_fps: int | None = Field(
None, description="Device-reported FPS (WLED internal refresh rate)"
)
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
device_streaming_reachable: Optional[bool] = Field(
device_last_checked: datetime | None = Field(None, description="Last health check time")
device_error: str | None = Field(None, description="Last health check error")
device_streaming_reachable: bool | None = Field(
None, description="Device reachable during streaming (HTTP probe)"
)
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
fps_effective: int | None = Field(None, description="Effective FPS after adaptive reduction")
class TargetMetricsResponse(BaseModel):
"""Target metrics response."""
target_id: str = Field(description="Target ID")
device_id: Optional[str] = Field(None, description="Device ID")
device_id: str | None = Field(None, description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS")
fps_target: Optional[int] = Field(None, description="Target FPS")
fps_actual: float | None = Field(None, description="Actual FPS")
fps_target: int | None = Field(None, description="Target FPS")
uptime_seconds: float = Field(description="Processing uptime in seconds")
frames_processed: int = Field(description="Total frames processed")
errors_count: int = Field(description="Total error count")
last_error: Optional[str] = Field(None, description="Last error message")
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
last_error: str | None = Field(None, description="Last error message")
last_update: datetime | None = Field(None, description="Last update timestamp")
class BulkTargetRequest(BaseModel):
@@ -1,7 +1,7 @@
"""Pydantic schemas for pattern template API."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class PatternTemplateCreate(BaseModel):
rectangles: List[KeyColorRectangleSchema] = Field(
default_factory=list, description="List of named rectangles"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class PatternTemplateCreate(BaseModel):
class PatternTemplateUpdate(BaseModel):
"""Request to update a pattern template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
rectangles: Optional[List[KeyColorRectangleSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
rectangles: List[KeyColorRectangleSchema] | None = Field(
None, description="List of named rectangles"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -59,13 +59,13 @@ class PatternTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Picture source schemas — discriminated unions per stream type."""
from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union
from typing import Annotated, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag
@@ -15,16 +15,16 @@ class _PictureSourceResponseBase(BaseModel):
id: str = Field(description="Stream ID")
name: str = Field(description="Stream name")
description: Optional[str] = Field(None, description="Stream description")
description: str | None = Field(None, description="Stream description")
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(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -46,28 +46,26 @@ class ProcessedPictureSourceResponse(_PictureSourceResponseBase):
class StaticImagePictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
image_asset_id: str | None = Field(None, description="Image asset ID")
class VideoPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
video_asset_id: str | None = Field(None, description="Video asset ID")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier")
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
end_time: Optional[float] = Field(None, description="Trim end time in seconds")
resolution_limit: Optional[int] = Field(None, description="Max width for decode")
clock_id: Optional[str] = Field(None, description="Sync clock ID")
start_time: float | None = Field(None, description="Trim start time in seconds")
end_time: float | None = Field(None, description="Trim end time in seconds")
resolution_limit: int | None = Field(None, description="Max width for decode")
clock_id: str | None = Field(None, description="Sync clock ID")
target_fps: int = Field(30, description="Target FPS")
PictureSourceResponse = Annotated[
Union[
Annotated[RawPictureSourceResponse, Tag("raw")],
Annotated[ProcessedPictureSourceResponse, Tag("processed")],
Annotated[StaticImagePictureSourceResponse, Tag("static_image")],
Annotated[VideoPictureSourceResponse, Tag("video")],
],
Annotated[RawPictureSourceResponse, Tag("raw")]
| Annotated[ProcessedPictureSourceResponse, Tag("processed")]
| Annotated[StaticImagePictureSourceResponse, Tag("static_image")]
| Annotated[VideoPictureSourceResponse, Tag("video")],
Discriminator("stream_type"),
]
@@ -80,14 +78,14 @@ class _PictureSourceCreateBase(BaseModel):
"""Shared fields for all picture source create requests."""
name: str = Field(description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
description: str | None = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -117,22 +115,20 @@ class VideoPictureSourceCreate(_PictureSourceCreateBase):
video_asset_id: str = Field(description="Video asset ID")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(
start_time: float | None = Field(None, description="Trim start time in seconds", ge=0)
end_time: float | None = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: int | None = Field(
None, description="Max width in pixels for decode downscale", ge=64, le=7680
)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
clock_id: str | None = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
PictureSourceCreate = Annotated[
Union[
Annotated[RawPictureSourceCreate, Tag("raw")],
Annotated[ProcessedPictureSourceCreate, Tag("processed")],
Annotated[StaticImagePictureSourceCreate, Tag("static_image")],
Annotated[VideoPictureSourceCreate, Tag("video")],
],
Annotated[RawPictureSourceCreate, Tag("raw")]
| Annotated[ProcessedPictureSourceCreate, Tag("processed")]
| Annotated[StaticImagePictureSourceCreate, Tag("static_image")]
| Annotated[VideoPictureSourceCreate, Tag("video")],
Discriminator("stream_type"),
]
@@ -144,15 +140,15 @@ PictureSourceCreate = Annotated[
class _PictureSourceUpdateBase(BaseModel):
"""Shared fields for all picture source update requests."""
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(
name: str | None = Field(None, description="Stream name", min_length=1, max_length=100)
description: str | None = Field(None, description="Stream description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -161,47 +157,43 @@ class _PictureSourceUpdateBase(BaseModel):
class RawPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["raw"] = "raw"
display_index: Optional[int] = Field(None, description="Display index", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
display_index: int | None = Field(None, description="Display index", ge=0)
capture_template_id: str | None = Field(None, description="Capture template ID")
target_fps: int | None = Field(None, description="Target FPS", ge=1, le=90)
class ProcessedPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(
None, description="Postprocessing template ID"
)
source_stream_id: str | None = Field(None, description="Source stream ID")
postprocessing_template_id: str | None = Field(None, description="Postprocessing template ID")
class StaticImagePictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
image_asset_id: str | None = Field(None, description="Image asset ID")
class VideoPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(
video_asset_id: str | None = Field(None, description="Video asset ID")
loop: bool | None = Field(None, description="Loop video playback")
playback_speed: float | None = Field(
None, description="Playback speed multiplier", ge=0.1, le=10.0
)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(
start_time: float | None = Field(None, description="Trim start time in seconds", ge=0)
end_time: float | None = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: int | None = Field(
None, description="Max width in pixels for decode downscale", ge=64, le=7680
)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
clock_id: str | None = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: int | None = Field(None, description="Target FPS", ge=1, le=90)
PictureSourceUpdate = Annotated[
Union[
Annotated[RawPictureSourceUpdate, Tag("raw")],
Annotated[ProcessedPictureSourceUpdate, Tag("processed")],
Annotated[StaticImagePictureSourceUpdate, Tag("static_image")],
Annotated[VideoPictureSourceUpdate, Tag("video")],
],
Annotated[RawPictureSourceUpdate, Tag("raw")]
| Annotated[ProcessedPictureSourceUpdate, Tag("processed")]
| Annotated[StaticImagePictureSourceUpdate, Tag("static_image")]
| Annotated[VideoPictureSourceUpdate, Tag("video")],
Discriminator("stream_type"),
]
@@ -246,7 +238,7 @@ class ImageValidateResponse(BaseModel):
"""Response from image validation."""
valid: bool = Field(description="Whether the image source is accessible and valid")
width: Optional[int] = Field(None, description="Image width in pixels")
height: Optional[int] = Field(None, description="Image height in pixels")
preview: Optional[str] = Field(None, description="Base64-encoded JPEG thumbnail")
error: Optional[str] = Field(None, description="Error message if invalid")
width: int | None = Field(None, description="Image width in pixels")
height: int | None = Field(None, description="Image height in pixels")
preview: str | None = Field(None, description="Base64-encoded JPEG thumbnail")
error: str | None = Field(None, description="Error message if invalid")
@@ -1,7 +1,7 @@
"""Postprocessing template schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class PostprocessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class PostprocessingTemplateCreate(BaseModel):
class PostprocessingTemplateUpdate(BaseModel):
"""Request to update a postprocessing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -59,13 +59,13 @@ class PostprocessingTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+13 -15
View File
@@ -1,7 +1,7 @@
"""Scene preset API schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -19,16 +19,14 @@ class ScenePresetCreate(BaseModel):
name: str = Field(description="Preset name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
target_ids: Optional[List[str]] = Field(
None, description="Target IDs to capture (all if omitted)"
)
target_ids: List[str] | None = Field(None, description="Target IDs to capture (all if omitted)")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -38,20 +36,20 @@ class ScenePresetCreate(BaseModel):
class ScenePresetUpdate(BaseModel):
"""Update scene preset metadata and optionally change which targets are included."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
order: Optional[int] = None
target_ids: Optional[List[str]] = Field(
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = Field(None, max_length=500)
order: int | None = None
target_ids: List[str] | None = Field(
None,
description="Update target list: keep state for existing, capture fresh for new, drop removed",
)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -67,12 +65,12 @@ class ScenePresetResponse(BaseModel):
targets: List[TargetSnapshotSchema]
order: int
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+13 -13
View File
@@ -1,7 +1,7 @@
"""Sync clock schemas (CRUD + control)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -11,14 +11,14 @@ class SyncClockCreate(BaseModel):
name: str = Field(description="Clock name", min_length=1, max_length=100)
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)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -28,16 +28,16 @@ class SyncClockCreate(BaseModel):
class SyncClockUpdate(BaseModel):
"""Request to update a synchronization clock."""
name: Optional[str] = Field(None, description="Clock name", min_length=1, max_length=100)
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(
name: str | None = Field(None, description="Clock name", min_length=1, max_length=100)
speed: float | None = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -50,14 +50,14 @@ class SyncClockResponse(BaseModel):
id: str = Field(description="Clock ID")
name: str = Field(description="Clock name")
speed: float = Field(description="Speed multiplier")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
+14 -14
View File
@@ -1,7 +1,7 @@
"""Capture template and engine schemas."""
from datetime import datetime
from typing import Dict, List, Optional
from typing import Dict, List
from pydantic import BaseModel, Field
@@ -12,14 +12,14 @@ class TemplateCreate(BaseModel):
name: str = Field(description="Template name", min_length=1, max_length=100)
engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -29,17 +29,17 @@ class TemplateCreate(BaseModel):
class TemplateUpdate(BaseModel):
"""Request to update a template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: Optional[str] = Field(None, description="Capture engine type (mss, dxcam, wgc)")
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(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: str | None = Field(None, description="Capture engine type (mss, dxcam, wgc)")
engine_config: Dict | None = Field(None, description="Engine-specific configuration")
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -56,11 +56,11 @@ class TemplateResponse(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")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None, max_length=64, description="Icon id from the curated icon library."
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None, max_length=32, description="Optional CSS color override for the icon."
)
+140 -114
View File
@@ -1,7 +1,7 @@
"""Value source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union
from typing import Annotated, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag
@@ -15,14 +15,14 @@ class _ValueSourceResponseBase(BaseModel):
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
@@ -100,7 +100,7 @@ class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
speed: float = Field(description="Cycles per minute (ignored when clock_id is set)")
easing: str = Field(description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine")
clock_id: Optional[str] = Field(
clock_id: str | None = Field(
None, description="Optional sync clock ID for shared timing (overrides speed)"
)
@@ -151,22 +151,32 @@ 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")],
Annotated[AnimatedValueSourceResponse, Tag("animated")],
Annotated[AudioValueSourceResponse, Tag("audio")],
Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")],
Annotated[DaylightValueSourceResponse, Tag("daylight")],
Annotated[StaticColorValueSourceResponse, Tag("static_color")],
Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")],
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")],
],
Annotated[StaticValueSourceResponse, Tag("static")]
| Annotated[AnimatedValueSourceResponse, Tag("animated")]
| Annotated[AudioValueSourceResponse, Tag("audio")]
| Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")]
| Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")]
| Annotated[DaylightValueSourceResponse, Tag("daylight")]
| Annotated[StaticColorValueSourceResponse, Tag("static_color")]
| Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")]
| Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")]
| Annotated[HAEntityValueSourceResponse, Tag("ha_entity")]
| Annotated[GradientMapValueSourceResponse, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceResponse, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")]
| Annotated[HTTPValueSourceResponse, Tag("http")],
Discriminator("source_type"),
]
@@ -179,14 +189,14 @@ class _ValueSourceCreateBase(BaseModel):
"""Shared fields for all value source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -264,7 +274,7 @@ class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
easing: str = Field(
"linear", description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
)
clock_id: Optional[str] = Field(
clock_id: str | None = Field(
None, description="Optional sync clock ID (overrides speed when set)"
)
@@ -310,22 +320,31 @@ 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")],
Annotated[AnimatedValueSourceCreate, Tag("animated")],
Annotated[AudioValueSourceCreate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceCreate, Tag("daylight")],
Annotated[StaticColorValueSourceCreate, Tag("static_color")],
Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")],
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")],
],
Annotated[StaticValueSourceCreate, Tag("static")]
| Annotated[AnimatedValueSourceCreate, Tag("animated")]
| Annotated[AudioValueSourceCreate, Tag("audio")]
| Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")]
| Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")]
| Annotated[DaylightValueSourceCreate, Tag("daylight")]
| Annotated[StaticColorValueSourceCreate, Tag("static_color")]
| Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")]
| Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")]
| Annotated[HAEntityValueSourceCreate, Tag("ha_entity")]
| Annotated[GradientMapValueSourceCreate, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceCreate, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")]
| Annotated[HTTPValueSourceCreate, Tag("http")],
Discriminator("source_type"),
]
@@ -337,15 +356,15 @@ ValueSourceCreate = Annotated[
class _ValueSourceUpdateBase(BaseModel):
"""Shared fields for all value source update requests."""
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(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -354,131 +373,138 @@ class _ValueSourceUpdateBase(BaseModel):
class StaticValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static"] = "static"
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
value: float | None = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
class AnimatedValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated"] = "animated"
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
waveform: str | None = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: float | None = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AudioValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["audio"] = "audio"
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
audio_source_id: str | None = Field(None, description="Mono audio source ID")
mode: str | None = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: float | None = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: float | None = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
auto_gain: bool | None = Field(None, description="Auto-normalize audio levels")
class AdaptiveTimeValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
schedule: list | None = Field(None, description="Time-of-day schedule")
min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AdaptiveSceneValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
picture_source_id: str | None = Field(None, description="Picture source ID")
scene_behavior: str | None = Field(None, description="Scene behavior")
sensitivity: float | None = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: float | None = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["daylight"] = "daylight"
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
None, description="Geographic longitude", ge=-180.0, le=180.0
)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
speed: float | None = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: float | None = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
longitude: float | None = Field(None, description="Geographic longitude", ge=-180.0, le=180.0)
min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class StaticColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static_color"] = "static_color"
color: Optional[List[int]] = Field(None, description="Static RGB color [R,G,B]")
color: List[int] | None = Field(None, description="Static RGB color [R,G,B]")
class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated_color"] = "animated_color"
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: Optional[str] = Field(
colors: List[List[int]] | None = Field(None, description="Color list [[R,G,B], ...]")
speed: float | None = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: str | None = Field(
None, description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
)
clock_id: Optional[str] = Field(
clock_id: str | None = Field(
None, description="Optional sync clock ID (empty string clears, null leaves unchanged)"
)
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
schedule: Optional[list] = Field(None, description="Color schedule")
schedule: list | None = Field(None, description="Color schedule")
class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["ha_entity"] = "ha_entity"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
entity_id: Optional[str] = Field(None, description="HA entity ID")
attribute: Optional[str] = Field(None, description="Attribute name")
min_ha_value: Optional[float] = Field(None, description="Min HA value")
max_ha_value: Optional[float] = Field(None, description="Max HA value")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
ha_source_id: str | None = Field(None, description="Home Assistant source ID")
entity_id: str | None = Field(None, description="HA entity ID")
attribute: str | None = Field(None, description="Attribute name")
min_ha_value: float | None = Field(None, description="Min HA value")
max_ha_value: float | None = Field(None, description="Max HA value")
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["gradient_map"] = "gradient_map"
value_source_id: Optional[str] = Field(None, description="Input value source ID")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
easing: Optional[str] = Field(None, description="Interpolation mode")
value_source_id: str | None = Field(None, description="Input value source ID")
gradient_id: str | None = Field(None, description="Gradient entity ID")
easing: str | None = Field(None, description="Interpolation mode")
class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["css_extract"] = "css_extract"
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
led_start: Optional[int] = Field(None, description="LED range start", ge=0)
led_end: Optional[int] = Field(None, description="LED range end")
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
led_start: int | None = Field(None, description="LED range start", ge=0)
led_end: int | None = Field(None, description="LED range end")
class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["system_metrics"] = "system_metrics"
metric: Optional[str] = Field(None, description="System metric")
min_value: Optional[float] = Field(None, description="Min value")
max_value: Optional[float] = Field(None, description="Max value")
max_rate: Optional[float] = Field(None, description="Max rate bytes/sec")
disk_path: Optional[str] = Field(None, description="Disk path")
sensor_label: Optional[str] = Field(None, description="Sensor label")
poll_interval: Optional[float] = Field(None, description="Poll interval", ge=0.1, le=60.0)
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
metric: str | None = Field(None, description="System metric")
min_value: float | None = Field(None, description="Min value")
max_value: float | None = Field(None, description="Max value")
max_rate: float | None = Field(None, description="Max rate bytes/sec")
disk_path: str | None = Field(None, description="Disk path")
sensor_label: str | None = Field(None, description="Sensor label")
poll_interval: float | None = Field(None, description="Poll interval", ge=0.1, le=60.0)
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["http"] = "http"
http_endpoint_id: str | None = Field(None, description="HTTP endpoint ID")
json_path: str | None = Field(None, description="Dot-path into the response")
interval_s: int | None = Field(None, description="Polling cadence (seconds)", ge=1)
min_value: float | None = Field(None, description="Raw value mapped to 0.0")
max_value: float | None = Field(None, description="Raw value mapped to 1.0")
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
ValueSourceUpdate = Annotated[
Union[
Annotated[StaticValueSourceUpdate, Tag("static")],
Annotated[AnimatedValueSourceUpdate, Tag("animated")],
Annotated[AudioValueSourceUpdate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceUpdate, Tag("daylight")],
Annotated[StaticColorValueSourceUpdate, Tag("static_color")],
Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")],
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")],
],
Annotated[StaticValueSourceUpdate, Tag("static")]
| Annotated[AnimatedValueSourceUpdate, Tag("animated")]
| Annotated[AudioValueSourceUpdate, Tag("audio")]
| Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")]
| Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")]
| Annotated[DaylightValueSourceUpdate, Tag("daylight")]
| Annotated[StaticColorValueSourceUpdate, Tag("static_color")]
| Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")]
| Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")]
| Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")]
| Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")]
| Annotated[HTTPValueSourceUpdate, Tag("http")],
Discriminator("source_type"),
]
@@ -1,7 +1,7 @@
"""Weather source schemas (CRUD)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from typing import Dict, List, Literal
from pydantic import BaseModel, Field
@@ -13,7 +13,7 @@ class WeatherSourceCreate(BaseModel):
provider: Literal["open_meteo"] = Field(
default="open_meteo", description="Weather data provider"
)
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
provider_config: Dict | None = Field(None, description="Provider-specific configuration")
latitude: float = Field(
default=50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0
)
@@ -23,14 +23,14 @@ class WeatherSourceCreate(BaseModel):
update_interval: int = Field(
default=600, description="API poll interval in seconds (60-3600)", ge=60, le=3600
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -40,26 +40,26 @@ class WeatherSourceCreate(BaseModel):
class WeatherSourceUpdate(BaseModel):
"""Request to update a weather source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
provider: Optional[Literal["open_meteo"]] = Field(None, description="Weather data provider")
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
latitude: Optional[float] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
provider: Literal["open_meteo"] | None = Field(None, description="Weather data provider")
provider_config: Dict | None = Field(None, description="Provider-specific configuration")
latitude: float | None = Field(
None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0
)
longitude: Optional[float] = Field(
longitude: float | None = Field(
None, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0
)
update_interval: Optional[int] = Field(
update_interval: int | None = Field(
None, description="API poll interval in seconds (60-3600)", ge=60, le=3600
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -78,14 +78,14 @@ class WeatherSourceResponse(BaseModel):
latitude: float = Field(description="Geographic latitude")
longitude: float = Field(description="Geographic longitude")
update_interval: int = Field(description="API poll interval in seconds")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
@@ -11,7 +11,7 @@ capture stream (WASAPI, sounddevice, etc.).
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Tuple
from ledgrab.core.audio.analysis import (
AudioAnalysis,
@@ -49,7 +49,7 @@ class ManagedAudioStream:
engine_type: str,
device_index: int,
is_loopback: bool,
engine_config: Optional[Dict[str, Any]] = None,
engine_config: Dict[str, Any] | None = None,
):
self._engine_type = engine_type
self._device_index = device_index
@@ -57,9 +57,9 @@ class ManagedAudioStream:
self._engine_config = engine_config or {}
self._running = False
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
self._latest: Optional[AudioAnalysis] = None
self._latest: AudioAnalysis | None = None
self._last_timing: dict = {}
def start(self) -> None:
@@ -90,7 +90,7 @@ class ManagedAudioStream:
f"device={self._device_index}"
)
def get_latest_analysis(self) -> Optional[AudioAnalysis]:
def get_latest_analysis(self) -> AudioAnalysis | None:
with self._lock:
return self._latest
@@ -98,7 +98,7 @@ class ManagedAudioStream:
return dict(self._last_timing)
def _capture_loop(self) -> None:
stream: Optional[AudioCaptureStreamBase] = None
stream: AudioCaptureStreamBase | None = None
try:
stream = AudioEngineRegistry.create_stream(
self._engine_type,
@@ -178,8 +178,8 @@ class AudioCaptureManager:
self,
device_index: int,
is_loopback: bool,
engine_type: Optional[str] = None,
engine_config: Optional[Dict[str, Any]] = None,
engine_type: str | None = None,
engine_config: Dict[str, Any] | None = None,
) -> ManagedAudioStream:
"""Get or create a ManagedAudioStream for the given device.
@@ -220,7 +220,7 @@ class AudioCaptureManager:
self,
device_index: int,
is_loopback: bool,
engine_type: Optional[str] = None,
engine_type: str | None = None,
) -> None:
"""Release a reference to a ManagedAudioStream."""
if engine_type is None:
+2 -2
View File
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -83,7 +83,7 @@ class AudioCaptureStreamBase(ABC):
pass
@abstractmethod
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
"""Read one chunk of raw audio data.
Returns:
+2 -2
View File
@@ -1,7 +1,7 @@
"""Demo audio engine — virtual audio devices with synthetic audio data."""
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -62,7 +62,7 @@ class DemoAudioCaptureStream(AudioCaptureStreamBase):
self._initialized = False
logger.info(f"Demo audio stream cleaned up (device={self.device_index})")
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
if not self._initialized:
return None
+2 -2
View File
@@ -1,6 +1,6 @@
"""Engine registry and factory for audio capture engines."""
from typing import Any, Dict, List, Optional, Type
from typing import Any, Dict, List, Type
from ledgrab.core.audio.base import AudioCaptureEngine, AudioCaptureStreamBase
from ledgrab.config import is_demo_mode
@@ -82,7 +82,7 @@ class AudioEngineRegistry:
return available
@classmethod
def get_best_available_engine(cls) -> Optional[str]:
def get_best_available_engine(cls) -> str | None:
"""Get the highest-priority available engine type.
Returns:
@@ -8,7 +8,6 @@ from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.band_filter import apply_band_filter, compute_band_mask
# Preset frequency ranges
_PRESETS = {
"bass": (20.0, 250.0),
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from ledgrab.core.audio.analysis import AudioAnalysis
@@ -20,8 +20,8 @@ class AudioFilterOptionDef:
min_value: Any
max_value: Any
step: Any
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
max_length: Optional[int] = None # for "string" type
choices: List[Dict[str, str]] | None = None # for "select": [{value, label}]
max_length: int | None = None # for "string" type
def to_dict(self) -> dict:
d = {
@@ -1,6 +1,6 @@
"""Sounddevice audio capture engine (cross-platform, via PortAudio)."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -79,7 +79,7 @@ class SounddeviceCaptureStream(AudioCaptureStreamBase):
self._sd_stream = None
self._initialized = False
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
if self._sd_stream is None:
return None
try:
@@ -1,6 +1,6 @@
"""WASAPI audio capture engine (Windows only, via PyAudioWPatch)."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -98,7 +98,7 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
self._pa = None
self._initialized = False
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
if self._stream is None:
return None
try:
@@ -109,7 +109,7 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
return None
@staticmethod
def _find_loopback_device(pa, output_device_index: int) -> Optional[dict]:
def _find_loopback_device(pa, output_device_index: int) -> dict | None:
"""Find the PyAudioWPatch loopback device for a given output device."""
try:
first_loopback = None
@@ -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, 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: str | None
topmost_fullscreen: bool
fullscreen_procs: Set[str]
idle_seconds: float | None
display_state: str | None
def _apply_operator(operator: str, extracted, expected: str) -> bool:
"""Compare *extracted* against *expected* using *operator*.
String operators (equals, not_equals, contains, regex) coerce the
extracted value to str. Numeric operators (gt, lt) coerce both sides
to float and return False on parse failure.
"""
if operator == "equals":
return str(extracted) == expected
if operator == "not_equals":
return str(extracted) != expected
if operator == "contains":
return expected in str(extracted)
if operator == "regex":
try:
return bool(re.search(expected, str(extracted)))
except re.error as exc:
logger.debug("HTTP poll rule regex error: %s", exc)
return False
if operator in ("gt", "lt"):
try:
lhs = float(extracted)
rhs = float(expected)
except (TypeError, ValueError):
return False
return lhs > rhs if operator == "gt" else lhs < rhs
return False
class AutomationEngine:
"""Evaluates automation rules and activates/deactivates scene presets."""
@@ -38,17 +87,21 @@ class AutomationEngine:
device_store=None,
ha_manager=None,
mqtt_manager=None,
value_stream_manager=None,
value_source_store=None,
):
self._store = automation_store
self._manager = processor_manager
self._poll_interval = poll_interval
self._detector = PlatformDetector()
self._mqtt_manager = mqtt_manager
self._value_stream_manager = value_stream_manager
self._value_source_store = value_source_store
self._scene_preset_store = scene_preset_store
self._target_store = target_store
self._device_store = device_store
self._ha_manager = ha_manager
self._task: Optional[asyncio.Task] = None
self._task: asyncio.Task | None = None
self._eval_lock = asyncio.Lock()
# Runtime state (not persisted)
@@ -65,12 +118,15 @@ class AutomationEngine:
self._ha_acquired: Set[str] = set()
# MQTT source IDs currently acquired by the engine
self._mqtt_acquired: Set[str] = set()
# Value source IDs currently acquired by the engine (for HTTPPollRule)
self._value_sources_acquired: Set[str] = set()
async def start(self) -> None:
if self._task is not None:
return
await self._sync_ha_runtimes()
await self._sync_mqtt_runtimes()
self._sync_value_stream_refs()
self._task = asyncio.create_task(self._poll_loop())
logger.info("Automation engine started")
@@ -94,6 +150,8 @@ class AutomationEngine:
await self._release_all_ha_runtimes()
# Release all MQTT runtimes
await self._release_all_mqtt_runtimes()
# Release all value-stream refs held for HTTPPollRule evaluation
self._release_all_value_stream_refs()
logger.info("Automation engine stopped")
@@ -183,6 +241,53 @@ class AutomationEngine:
logger.warning("Failed to release MQTT runtime %s: %s", source_id, e)
self._mqtt_acquired = set()
def _get_needed_value_sources(self) -> Set[str]:
"""Collect value source IDs referenced by enabled HTTPPollRule rules."""
needed: Set[str] = set()
if self._value_stream_manager is None:
return needed
for a in self._store.get_all_automations():
if a.enabled:
for r in a.rules:
if isinstance(r, HTTPPollRule) and r.value_source_id:
needed.add(r.value_source_id)
return needed
def _sync_value_stream_refs(self) -> None:
"""Acquire/release ValueStreams to keep HTTPPollRule sources polling.
Mirrors the HA/MQTT sync pattern, but talks to ``ValueStreamManager``
(which is sync). Acquiring a stream both starts its background poll
task and pins the ref count; releasing decrements.
"""
if self._value_stream_manager is None:
return
needed = self._get_needed_value_sources()
for vs_id in self._value_sources_acquired - needed:
try:
self._value_stream_manager.release(vs_id)
logger.debug("Released value stream for automation: %s", vs_id)
except Exception as e:
logger.warning("Failed to release value stream %s: %s", vs_id, e)
for vs_id in needed - self._value_sources_acquired:
try:
self._value_stream_manager.acquire(vs_id)
logger.debug("Acquired value stream for automation: %s", vs_id)
except Exception as e:
logger.warning("Failed to acquire value stream %s: %s", vs_id, e)
self._value_sources_acquired = needed
def _release_all_value_stream_refs(self) -> None:
"""Release all ValueStreams held for HTTPPollRule evaluation."""
if self._value_stream_manager is None:
return
for vs_id in self._value_sources_acquired:
try:
self._value_stream_manager.release(vs_id)
except Exception as e:
logger.warning("Failed to release value stream %s: %s", vs_id, e)
self._value_sources_acquired = set()
async def _poll_loop(self) -> None:
try:
while True:
@@ -198,6 +303,7 @@ class AutomationEngine:
async def _evaluate_all(self) -> None:
await self._sync_ha_runtimes()
await self._sync_mqtt_runtimes()
self._sync_value_stream_refs()
async with self._eval_lock:
await self._evaluate_all_locked()
@@ -314,11 +420,11 @@ class AutomationEngine:
self,
automation: Automation,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_proc: str | None,
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float],
display_state: Optional[str],
idle_seconds: float | None,
display_state: str | None,
) -> bool:
results = [
self._evaluate_rule(
@@ -337,32 +443,79 @@ 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,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_proc: str | None,
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float],
display_state: Optional[str],
idle_seconds: float | None,
display_state: str | None,
) -> 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:
@@ -378,14 +531,14 @@ class AutomationEngine:
return current >= start or current <= end
@staticmethod
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: Optional[float]) -> bool:
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
if idle_seconds is None:
return False
is_idle = idle_seconds >= (rule.idle_minutes * 60)
return is_idle if rule.when_idle else not is_idle
@staticmethod
def _evaluate_display_state(rule: DisplayStateRule, display_state: Optional[str]) -> bool:
def _evaluate_display_state(rule: DisplayStateRule, display_state: str | None) -> bool:
if display_state is None:
return False
return display_state == rule.state
@@ -436,11 +589,30 @@ 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,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_proc: str | None,
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
) -> bool:
@@ -636,3 +808,57 @@ class AutomationEngine:
"""Deactivate an automation immediately (used when disabling/deleting)."""
if automation_id in self._active_automations:
await self._deactivate_automation(automation_id)
# Bind the per-rule-type handler table once after the class is fully defined.
# This replaces the per-call dict-rebuild that the inline ``_evaluate_rule``
# used to do and gives us a single place to assert coverage against the
# Rule subclass set imported from storage.
AutomationEngine._RULE_HANDLERS = {
StartupRule: AutomationEngine._handle_startup,
ApplicationRule: AutomationEngine._handle_application,
TimeOfDayRule: AutomationEngine._handle_time_of_day,
SystemIdleRule: AutomationEngine._handle_system_idle,
DisplayStateRule: AutomationEngine._handle_display_state,
MQTTRule: AutomationEngine._handle_mqtt,
WebhookRule: AutomationEngine._handle_webhook,
HomeAssistantRule: AutomationEngine._handle_home_assistant,
HTTPPollRule: AutomationEngine._handle_http_poll,
}
def _assert_rule_handler_coverage() -> None:
"""Every concrete Rule subclass imported by this module must have a handler.
Runs at module import so a new Rule subclass added without an
accompanying ``_handle_*`` method + ``_RULE_HANDLERS`` entry fails the
server boot loudly instead of silently being dropped on the floor by
``_evaluate_rule``'s "no handler → False" fallback.
"""
expected = {
StartupRule,
ApplicationRule,
TimeOfDayRule,
SystemIdleRule,
DisplayStateRule,
MQTTRule,
WebhookRule,
HomeAssistantRule,
HTTPPollRule,
}
registered = set(AutomationEngine._RULE_HANDLERS.keys())
missing = expected - registered
extra = registered - expected
if missing or extra:
problems = []
if missing:
problems.append(f"missing handlers: {sorted(c.__name__ for c in missing)}")
if extra:
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
raise RuntimeError(
"AutomationEngine._RULE_HANDLERS is out of sync with imported Rule subclasses: "
+ "; ".join(problems)
)
_assert_rule_handler_coverage()
@@ -9,7 +9,7 @@ import ctypes
import os
import sys
import threading
from typing import Optional, Set
from typing import Set
from ledgrab.utils import get_logger
@@ -164,7 +164,7 @@ class PlatformDetector:
except Exception as e:
logger.error(f"Display power listener failed: {e}")
def _get_display_power_state_sync(self) -> Optional[str]:
def _get_display_power_state_sync(self) -> str | None:
"""Get display power state: 'on' or 'off'. Returns None if unavailable."""
if not _IS_WINDOWS:
return None
@@ -172,7 +172,7 @@ class PlatformDetector:
# ---- System idle detection ----
def _get_idle_seconds_sync(self) -> Optional[float]:
def _get_idle_seconds_sync(self) -> float | None:
"""Get system idle time in seconds (keyboard/mouse inactivity).
Returns None if detection is unavailable.
@@ -4,7 +4,7 @@ import asyncio
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import List, Optional
from typing import List
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
@@ -33,8 +33,8 @@ class AutoBackupEngine:
):
self._backup_dir = Path(backup_dir)
self._db = db
self._task: Optional[asyncio.Task] = None
self._last_backup_time: Optional[datetime] = None
self._task: asyncio.Task | None = None
self._last_backup_time: datetime | None = None
self._settings = self._load_settings()
self._backup_dir.mkdir(parents=True, exist_ok=True)
+15 -169
View File
@@ -5,6 +5,10 @@ from typing import Dict, List, Literal, Set, Tuple
import numpy as np
from ledgrab.core.capture.edge_interpolation import (
average_edge_to_leds,
fallback_edge_to_leds,
)
from ledgrab.core.capture.screen_capture import (
BorderPixels,
calculate_average_color,
@@ -404,107 +408,17 @@ class PixelMapper:
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
) -> np.ndarray:
"""Per-LED color mapping for median/dominant modes. Returns (led_count, 3) uint8."""
if edge_name in ("top", "bottom"):
edge_len = edge_pixels.shape[1]
else:
edge_len = edge_pixels.shape[0]
step = edge_len / led_count
result = np.empty((led_count, 3), dtype=np.uint8)
for i in range(led_count):
start = int(i * step)
end = max(start + 1, int((i + 1) * step))
end = min(end, edge_len)
if edge_name in ("top", "bottom"):
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
color = self._calc_color(segment)
result[i] = color
return result
return fallback_edge_to_leds(edge_pixels, edge_name, led_count, self._calc_color)
def _map_edge_average(
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
) -> np.ndarray:
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8.
Uses pre-allocated cumsum/mean buffers AND pre-allocated output
buffers (lazy-initialized per edge). All per-frame numpy ops write
in-place — zero allocations on the hot path.
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
the shared kernel handles all allocations on first use.
"""
if edge_name in ("top", "bottom"):
axis = 0
edge_len = edge_pixels.shape[1]
else:
axis = 1
edge_len = edge_pixels.shape[0]
# Lazy-init / resize per-edge scratch buffers.
# float32 is sufficient: max cumsum value is edge_len * 255 (≈2M @ 8K
# screens) which fits exactly in float32's 24-bit mantissa. Halves
# memory bandwidth on the hot reduction.
cache = self._edge_cache.get(edge_name)
if cache is None or cache[0] != edge_len or cache[1] != led_count:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
np.minimum(boundaries, edge_len, out=boundaries)
starts = boundaries[:-1]
ends = boundaries[1:]
lengths = (ends - starts).reshape(-1, 1).astype(np.float32)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float32)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float32)
sums_buf = np.empty((led_count, 3), dtype=np.float32)
starts_buf = np.empty((led_count, 3), dtype=np.float32)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
cache = (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
self._edge_cache[edge_name] = cache
(
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = cache
# Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
# Cumsum into pre-allocated buffer (cumsum_buf[0] left at 0 from init)
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
# segment_sums = cumsum_buf[ends] - cumsum_buf[starts] — but each
# fancy-index expression allocates. np.take with ``out=`` writes
# directly into our pre-allocated scratch.
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, edge_name)
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors.
@@ -669,64 +583,12 @@ class AdvancedPixelMapper:
led_count: int,
cache_key: int,
) -> np.ndarray:
"""Vectorized average-color mapping (same algo as PixelMapper)."""
if edge_name in ("top", "bottom"):
axis = 0
edge_len = edge_pixels.shape[1]
else:
axis = 1
edge_len = edge_pixels.shape[0]
"""Vectorized average-color mapping; delegates to the shared kernel.
cache = self._edge_cache.get(cache_key)
if cache is None or cache[0] != edge_len or cache[1] != led_count:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
np.minimum(boundaries, edge_len, out=boundaries)
starts = boundaries[:-1]
ends = boundaries[1:]
lengths = (ends - starts).reshape(-1, 1).astype(np.float32)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float32)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float32)
sums_buf = np.empty((led_count, 3), dtype=np.float32)
starts_buf = np.empty((led_count, 3), dtype=np.float32)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
cache = (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
self._edge_cache[cache_key] = cache
(
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = cache
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
``cache_key`` is an integer (e.g. line index) so multiple per-line
edges can share the same ``self._edge_cache`` dict without colliding.
"""
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, cache_key)
def _map_edge_fallback(
self,
@@ -734,24 +596,8 @@ class AdvancedPixelMapper:
edge_name: str,
led_count: int,
) -> np.ndarray:
"""Per-LED color mapping for median/dominant modes."""
if edge_name in ("top", "bottom"):
edge_len = edge_pixels.shape[1]
else:
edge_len = edge_pixels.shape[0]
step = edge_len / led_count
result = np.empty((led_count, 3), dtype=np.uint8)
for i in range(led_count):
start = int(i * step)
end = max(start + 1, int((i + 1) * step))
end = min(end, edge_len)
if edge_name in ("top", "bottom"):
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
result[i] = self._calc_color(segment)
return result
"""Per-LED color mapping for median/dominant modes; delegates to shared kernel."""
return fallback_edge_to_leds(edge_pixels, edge_name, led_count, self._calc_color)
def map_lines_to_leds(self, frames: Dict[str, np.ndarray]) -> np.ndarray:
"""Map multi-source frames to LED colors using calibration lines.
@@ -0,0 +1,163 @@
"""Shared edge-to-LED interpolation kernels for PixelMapper variants.
``PixelMapper`` and ``AdvancedPixelMapper`` in ``calibration.py`` historically
carried two byte-for-byte copies of:
* the fast vectorised "average across each LED segment" path
(``_map_edge_average``) — ~80 lines of buffer-allocation + cumsum tricks; and
* the per-LED-loop "median / dominant colour" path (``_map_edge_fallback``).
Lifting both kernels into pure functions removes the duplication and
keeps the algorithms in one place. Each mapper owns its own scratch-buffer
cache (keyed differently in the two cases — see callers); the functions
accept that cache as an in/out dict so allocations still happen once per
(edge_len, led_count) pair.
These functions intentionally do NOT touch the mappers' state beyond what
the callers pass in, so they are trivially testable in isolation.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Hashable, Tuple
import numpy as np
# Cache value layout — kept as a tuple for the small per-frame cost of
# tuple unpacking vs the readability of a dataclass. The first two entries
# are the (edge_len, led_count) signature used to detect a re-build.
_CacheEntry = Tuple[
int, # edge_len
int, # led_count
np.ndarray, # starts (int64, shape (led_count,))
np.ndarray, # ends (int64, shape (led_count,))
np.ndarray, # lengths (float32, shape (led_count, 1))
np.ndarray, # cumsum_buf (float32, shape (edge_len + 1, 3))
np.ndarray, # edge_1d_buf (float32, shape (edge_len, 3))
np.ndarray, # sums_buf (float32, shape (led_count, 3))
np.ndarray, # starts_buf (float32, shape (led_count, 3))
np.ndarray, # out_uint8 (uint8, shape (led_count, 3))
]
def _build_cache(edge_len: int, led_count: int) -> _CacheEntry:
"""Pre-allocate all scratch buffers for one (edge_len, led_count) pair."""
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
# Ensure monotonically increasing boundaries even when ``step`` < 1.
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
np.minimum(boundaries, edge_len, out=boundaries)
starts = boundaries[:-1]
ends = boundaries[1:]
lengths = (ends - starts).reshape(-1, 1).astype(np.float32)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float32)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float32)
sums_buf = np.empty((led_count, 3), dtype=np.float32)
starts_buf = np.empty((led_count, 3), dtype=np.float32)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
return (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
def average_edge_to_leds(
edge_pixels: np.ndarray,
edge_name: str,
led_count: int,
cache: Dict[Hashable, _CacheEntry],
cache_key: Hashable,
) -> np.ndarray:
"""Vectorised average colour per LED segment.
``edge_pixels`` is shape ``(H, W, 3)``. For top/bottom edges we average
over axis=0 (collapsing rows), then segment along the width; for
left/right edges we average over axis=1 then segment along the height.
Returns a view into the caller-owned cache's ``out_uint8`` buffer —
do NOT retain the result across calls without copying.
"""
if edge_name in ("top", "bottom"):
axis = 0
edge_len = edge_pixels.shape[1]
else:
axis = 1
edge_len = edge_pixels.shape[0]
entry = cache.get(cache_key)
if entry is None or entry[0] != edge_len or entry[1] != led_count:
entry = _build_cache(edge_len, led_count)
cache[cache_key] = entry
(
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = entry
# Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
# Cumulative sum so each LED segment's sum is two array lookups apart.
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
# segment_sum[i] = cumsum[ends[i]] - cumsum[starts[i]]
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
def fallback_edge_to_leds(
edge_pixels: np.ndarray,
edge_name: str,
led_count: int,
calc_color: Callable[[np.ndarray], Any],
) -> np.ndarray:
"""Per-LED colour mapping for median / dominant modes.
Iterates LED segments and delegates colour reduction to ``calc_color``
(which is e.g. ``np.median`` for median mode, ``_dominant_colour`` for
dominant). Slower than ``average_edge_to_leds`` but supports any
reducer over the segment's pixels.
"""
if edge_name in ("top", "bottom"):
edge_len = edge_pixels.shape[1]
else:
edge_len = edge_pixels.shape[0]
step = edge_len / led_count
result = np.empty((led_count, 3), dtype=np.uint8)
for i in range(led_count):
start = int(i * step)
end = max(start + 1, int((i + 1) * step))
end = min(end, edge_len)
if edge_name in ("top", "bottom"):
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
result[i] = calc_color(segment)
return result
@@ -1,13 +1,13 @@
"""Pixel processing utilities for color correction and manipulation."""
from typing import List, Tuple, Union
from typing import List, Tuple
import numpy as np
from ledgrab.utils import get_logger
logger = get_logger(__name__)
ColorList = Union[List[Tuple[int, int, int]], np.ndarray]
ColorList = List[Tuple[int, int, int]] | np.ndarray
def _as_array(colors: ColorList) -> np.ndarray:
@@ -14,6 +14,12 @@ from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rat
logger = get_logger(__name__)
# Reused random Generator for sampling. The legacy ``np.random.randint``
# uses the module-level RandomState which is slightly slower per-call and
# pulls in extra import-time work; a single Generator is faster and avoids
# global-state surprises.
_rng = np.random.default_rng()
@dataclass
class DisplayInfo:
@@ -326,7 +332,11 @@ def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
max_samples = 1000
if n > max_samples:
indices = np.random.randint(0, n, max_samples)
# ``Generator.integers`` writes into a fresh buffer once per call;
# the legacy ``np.random.randint`` did the same plus extra
# bookkeeping. Random (not stride) sampling stays robust against
# periodic patterns in screen pixels.
indices = _rng.integers(0, n, size=max_samples)
pixels_reshaped = pixels_reshaped[indices]
# Quantize to 32 levels/channel (drop low 3 bits) and pack into uint32:
@@ -6,7 +6,7 @@ import colorsys
import logging
import sys
import threading
from typing import TYPE_CHECKING, Dict, List, Optional
from typing import TYPE_CHECKING, Dict, List
if TYPE_CHECKING:
import tkinter as tk
@@ -41,8 +41,8 @@ class OverlayWindow:
self.calibration = calibration
self.target_id = target_id
self.target_name = target_name or target_id
self._window: Optional[tk.Toplevel] = None
self._canvas: Optional[tk.Canvas] = None
self._window: tk.Toplevel | None = None
self._canvas: tk.Canvas | None = None
self.running = False
# ----- Lifecycle (must run in Tk thread) -----
@@ -352,8 +352,8 @@ class OverlayManager:
def __init__(self):
self._overlays: Dict[str, OverlayWindow] = {}
self._lock = threading.Lock()
self._tk_root: Optional[tk.Tk] = None
self._tk_thread: Optional[threading.Thread] = None
self._tk_root: tk.Tk | None = None
self._tk_thread: threading.Thread | None = None
self._tk_ready = threading.Event()
self._start_tk_thread()
@@ -386,7 +386,7 @@ class OverlayManager:
if self._tk_root is None:
raise RuntimeError("Tkinter root not available")
done = threading.Event()
exc_box: List[Optional[BaseException]] = [None]
exc_box: List[BaseException | None] = [None]
def wrapper():
try:
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -70,7 +70,7 @@ class CaptureStream(ABC):
pass
@abstractmethod
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
"""Capture one frame from the bound display.
Returns:
@@ -2,7 +2,7 @@
import sys
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from ledgrab.core.capture_engines.base import (
@@ -104,7 +104,7 @@ class BetterCamCaptureStream(CaptureStream):
logger.error(f"BetterCam reinit failed (display={self.display_index}): {reinit_err}")
return False
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -13,7 +13,7 @@ import platform
import sys
import threading
import time
from typing import Any, Dict, List, Optional, Set
from typing import Any, Dict, List, Set
# OpenCV's MSMF backend on Windows often fails to open the device
# ("cap.isOpened() == False" right after VideoCapture returns) when
@@ -50,7 +50,7 @@ _RESOLUTION_CHOICES: List[str] = [
]
def _parse_resolution(value: Any) -> Optional[tuple[int, int]]:
def _parse_resolution(value: Any) -> tuple[int, int] | None:
"""Parse a 'WxH' string into (width, height). Returns None for 'auto' or invalid."""
if not isinstance(value, str):
return None
@@ -101,7 +101,7 @@ _BUILDINFO_LABELS: Dict[str, str] = {
"avfoundation": "AVFoundation",
}
_compiled_backends_cache: Optional[Set[str]] = None
_compiled_backends_cache: Set[str] | None = None
def _get_compiled_backends() -> Set[str]:
@@ -169,7 +169,7 @@ def _get_supported_backends() -> List[str]:
return ["auto", *(b for b in candidates if b in compiled)]
def _cv2_backend_id(backend_name: str) -> Optional[int]:
def _cv2_backend_id(backend_name: str) -> int | None:
"""Convert a backend name string to cv2 API preference constant."""
return _CV2_BACKENDS.get(backend_name)
@@ -307,7 +307,7 @@ def _get_camera_friendly_names() -> Dict[int, str]:
return {}
_camera_cache: Optional[List[Dict[str, Any]]] = None
_camera_cache: List[Dict[str, Any]] | None = None
_camera_cache_time: float = 0
_CAMERA_CACHE_TTL = 30.0 # seconds
@@ -428,7 +428,7 @@ class CameraCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._cap = None
self._cv2_index: Optional[int] = None
self._cv2_index: int | None = None
def initialize(self) -> None:
if self._initialized:
@@ -531,7 +531,7 @@ class CameraCaptureStream(CaptureStream):
f"(camera={camera['name']}, cv2_idx={cv2_index}, {w}x{h})"
)
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -1,7 +1,7 @@
"""Demo capture engine — virtual displays with animated test patterns."""
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -65,7 +65,7 @@ class DemoCaptureStream(CaptureStream):
self._initialized = False
logger.info(f"Demo capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -2,7 +2,7 @@
import sys
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from ledgrab.core.capture_engines.base import (
@@ -102,7 +102,7 @@ class DXcamCaptureStream(CaptureStream):
logger.error(f"DXcam reinit failed (display={self.display_index}): {reinit_err}")
return False
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -1,6 +1,6 @@
"""Engine registry and factory for screen capture engines."""
from typing import Any, Dict, List, Optional, Type
from typing import Any, Dict, List, Type
from ledgrab.core.capture_engines.base import CaptureEngine, CaptureStream
from ledgrab.config import is_demo_mode
@@ -83,7 +83,7 @@ class EngineRegistry:
return available
@classmethod
def get_best_available_engine(cls) -> Optional[str]:
def get_best_available_engine(cls) -> str | None:
"""Get the highest-priority available engine type.
Returns:
@@ -29,7 +29,7 @@ logger = get_logger(__name__)
# ---------------------------------------------------------------------------
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
_display_info: Optional[DisplayInfo] = None
_display_info: DisplayInfo | None = None
_active = False
_frames_received = 0
_frames_consumed = 0
@@ -141,7 +141,7 @@ class MediaProjectionCaptureStream(CaptureStream):
self._initialized = True
logger.info("MediaProjection capture stream initialized")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
# Prefer fresh frames from the queue; fall back to the last
@@ -1,6 +1,6 @@
"""MSS-based screen capture engine (cross-platform)."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import mss
import numpy as np
@@ -41,7 +41,7 @@ class MSSCaptureStream(CaptureStream):
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
self._prev_hash: int | None = None
def initialize(self) -> None:
try:
@@ -59,7 +59,7 @@ class MSSCaptureStream(CaptureStream):
self._prev_hash = None
logger.info(f"MSS capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -35,7 +35,7 @@ logger = get_logger(__name__)
# ---------------------------------------------------------------------------
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
_display_info: Optional[DisplayInfo] = None
_display_info: DisplayInfo | None = None
_active = False
_frames_received = 0
# screenrecord emits a full bitstream every frame (keyframes aside), so
@@ -123,7 +123,7 @@ class RootScreenrecordCaptureStream(CaptureStream):
self._initialized = True
logger.info("Root screenrecord capture stream initialized")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
try:
@@ -14,7 +14,7 @@ video stream. No APK installation, no root.
"""
import threading
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -96,12 +96,12 @@ class ScrcpyClientCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._client: Optional["scrcpy.Client"] = None
self._latest_frame: Optional[ScreenCapture] = None
self._client: "scrcpy.Client" | None = None
self._latest_frame: ScreenCapture | None = None
self._frame_lock = threading.Lock()
self._frame_event = threading.Event()
self._client_thread: Optional[threading.Thread] = None
self._device_serial: Optional[str] = None
self._client_thread: threading.Thread | None = None
self._device_serial: str | None = None
def initialize(self) -> None:
if self._initialized:
@@ -189,7 +189,7 @@ class ScrcpyClientCaptureStream(CaptureStream):
)
self._frame_event.set()
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -18,7 +18,7 @@ import shutil
import subprocess
import threading
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -78,7 +78,7 @@ def _find_adb() -> str:
return "adb" # last resort — will fail with FileNotFoundError
_adb_path: Optional[str] = None
_adb_path: str | None = None
def _get_adb() -> str:
@@ -158,7 +158,7 @@ def _list_adb_devices() -> List[Dict[str, Any]]:
return devices
def _screencap_once(adb: str, serial: str) -> Optional[np.ndarray]:
def _screencap_once(adb: str, serial: str) -> np.ndarray | None:
"""Capture a single PNG screenshot and return it as an RGB NumPy array."""
try:
result = subprocess.run(
@@ -190,12 +190,12 @@ class ScrcpyCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._capture_thread: Optional[threading.Thread] = None
self._latest_frame: Optional[ScreenCapture] = None
self._capture_thread: threading.Thread | None = None
self._latest_frame: ScreenCapture | None = None
self._frame_lock = threading.Lock()
self._frame_event = threading.Event()
self._running = False
self._device_serial: Optional[str] = None
self._device_serial: str | None = None
def initialize(self) -> None:
if self._initialized:
@@ -281,7 +281,7 @@ class ScrcpyCaptureStream(CaptureStream):
if poll_interval > 0:
time.sleep(poll_interval)
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -3,7 +3,7 @@
import gc
import sys
import threading
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -199,7 +199,7 @@ class WGCCaptureStream(CaptureStream):
gc.collect(0)
logger.info(f"WGC capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -3,7 +3,7 @@
import asyncio
import concurrent.futures
from datetime import datetime, timezone
from typing import Optional, Tuple
from typing import Tuple
import numpy as np
@@ -41,7 +41,7 @@ def _build_adalight_header(led_count: int) -> bytes:
class AdalightClient(LEDClient):
"""LED client for Arduino Adalight serial devices."""
def __init__(self, url: str, led_count: int = 0, baud_rate: Optional[int] = None, **kwargs):
def __init__(self, url: str, led_count: int = 0, baud_rate: int | None = None, **kwargs):
"""Initialize Adalight client.
Args:
@@ -62,11 +62,11 @@ class AdalightClient(LEDClient):
# Pre-allocated wire buffer (header + RGB payload). Resized on the
# first frame and reused thereafter so the hot path performs no
# allocations — only a single memcpy of the pixel bytes.
self._frame_buf: Optional[bytearray] = None
self._frame_buf: bytearray | None = None
self._frame_buf_n: int = 0
# Scratch uint8 array used to coerce non-uint8 / non-contiguous input
# without allocating a fresh array per frame.
self._u8_scratch: Optional[np.ndarray] = None
self._u8_scratch: np.ndarray | None = None
self._u8_scratch_n: int = 0
# Dedicated single-worker executor for serial writes. Using
# ``loop.run_in_executor`` against this avoids the per-call
@@ -74,7 +74,7 @@ class AdalightClient(LEDClient):
# that ``asyncio.to_thread`` incurs (~510 µs per call), and
# guarantees FIFO ordering of writes from this client even when
# other tasks are using the default executor.
self._tx_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
self._tx_executor: concurrent.futures.ThreadPoolExecutor | None = None
async def connect(self) -> bool:
"""Open serial port and wait for Arduino reset."""
@@ -245,7 +245,7 @@ class AdalightClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if the serial port exists without opening it.
@@ -10,7 +10,7 @@ so ``BLEClient`` can treat both backends identically.
from __future__ import annotations
import asyncio
from typing import List, Optional
from typing import List
from ledgrab.core.devices.ble_transport import DiscoveredBLEDevice
from ledgrab.utils import get_logger
@@ -49,7 +49,7 @@ async def android_ble_scan(timeout: float = 4.0) -> List[DiscoveredBLEDevice]:
continue
address, name, rssi_str = parts
try:
rssi: Optional[int] = int(rssi_str)
rssi: int | None = int(rssi_str)
except ValueError:
rssi = None
devices.append(DiscoveredBLEDevice(address=address, name=name or address, rssi=rssi))
@@ -80,7 +80,7 @@ class AndroidBLETransport:
self._address = address
self._write_char_uuid = write_char_uuid
self._write_with_response = write_with_response
self._handle: Optional[int] = None
self._handle: int | None = None
self._lock = asyncio.Lock()
@property
@@ -120,7 +120,7 @@ class AndroidBLETransport:
except Exception as exc:
logger.warning("Android BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes, char_uuid: Optional[str] = None) -> None:
async def write(self, data: bytes, char_uuid: str | None = None) -> None:
"""Write bytes to a characteristic on the connected peripheral.
Serialised through an internal lock — BLE stacks do not tolerate
@@ -8,7 +8,7 @@ optional ``@baud`` suffix).
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Tuple
from typing import List, Tuple
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
@@ -38,7 +38,7 @@ def _bridge():
class _UsbAddress:
vendor_id: int
product_id: int
serial: Optional[str]
serial: str | None
@classmethod
def parse(cls, device: str) -> "_UsbAddress":
@@ -59,7 +59,7 @@ class _UsbAddress:
return cls(vid, pid, serial)
def _format_url(vid: int, pid: int, serial: Optional[str]) -> str:
def _format_url(vid: int, pid: int, serial: str | None) -> str:
base = f"usb:{vid:04x}:{pid:04x}"
return f"{base}:{serial}" if serial else base
@@ -101,7 +101,7 @@ class AndroidSerialTransport:
self._url = device
self._addr = _UsbAddress.parse(device)
self._baud_rate = baud_rate
self._handle: Optional[int] = None # opaque token from the bridge
self._handle: int | None = None # opaque token from the bridge
@property
def is_open(self) -> bool:
@@ -17,7 +17,7 @@ from __future__ import annotations
import asyncio
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -92,7 +92,7 @@ class BLEClient(LEDClient):
write_with_response=self._protocol.write_with_response,
)
# AES key for Govee encrypted firmware — 16 raw bytes or None.
self._aes_key: Optional[bytes] = None
self._aes_key: bytes | None = None
if ble_govee_key and ble_family == "govee":
try:
import binascii
@@ -104,7 +104,7 @@ class BLEClient(LEDClient):
except Exception as exc:
logger.warning("Invalid Govee AES key — ignoring: %s", exc)
self._last_write_at: float = 0.0
self._last_color: Optional[Tuple[int, int, int, int]] = None
self._last_color: Tuple[int, int, int, int] | None = None
self._connected = False
# Throttle "not connected" warnings so the send loop doesn't spam logs
# at frame rate when a BLE connection drops silently.
@@ -161,12 +161,12 @@ class BLEClient(LEDClient):
return self._connected and self._transport.is_connected
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip to one color and write it — BLE protocols are whole-strip only."""
@@ -249,7 +249,7 @@ class BLEClient(LEDClient):
cls,
url: str,
http_client, # noqa: ARG003 — unused; kept for the LEDClient contract
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""BLE health isn't a passive check — a full GATT connect is the only signal.
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, List, Optional, Tuple
from typing import TYPE_CHECKING, List, Tuple
from ledgrab.core.devices.ble_client import BLEClient, _strip_ble_scheme
from ledgrab.core.devices.ble_protocols import (
@@ -165,7 +165,7 @@ class BLEDeviceProvider(LEDDeviceProvider):
]
def get_ble_provider() -> Optional["BLEDeviceProvider"]:
def get_ble_provider() -> "BLEDeviceProvider" | None:
"""Return the registered BLE provider, or ``None`` if not registered."""
from ledgrab.core.devices.led_client import get_provider
@@ -18,7 +18,7 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import List, Optional
from typing import List
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
@@ -65,7 +65,7 @@ class DiscoveredBLEDevice:
address: str
name: str
rssi: Optional[int]
rssi: int | None
service_uuids: tuple = ()
@@ -234,7 +234,7 @@ class BLETransport:
except Exception as exc:
logger.warning("BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes, char_uuid: Optional[str] = None) -> None:
async def write(self, data: bytes, char_uuid: str | None = None) -> None:
"""Send bytes to a GATT write characteristic.
Serialised through an internal lock — BLE stacks do not like
@@ -2,7 +2,7 @@
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -48,9 +48,9 @@ class ChromaClient(LEDClient):
self._base_url = url or CHROMA_SDK_URL
self._led_count = led_count
self._chroma_device_type = chroma_device_type
self._session_url: Optional[str] = None
self._session_url: str | None = None
self._connected = False
self._heartbeat_task: Optional[asyncio.Task] = None
self._heartbeat_task: asyncio.Task | None = None
self._http_client = None
async def connect(self) -> bool:
@@ -135,7 +135,7 @@ class ChromaClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self.is_connected or not self._http_client:
@@ -204,7 +204,7 @@ class ChromaClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if Chroma SDK is running."""
base = url or CHROMA_SDK_URL
@@ -3,7 +3,7 @@
import asyncio
import struct
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Tuple
import numpy as np
@@ -58,12 +58,12 @@ class DDPClient:
self._sequence = 0
self._buses: List[BusConfig] = []
# Pre-allocated RGBW buffer (resized on demand)
self._rgbw_buf: Optional[np.ndarray] = None
self._rgbw_buf: np.ndarray | None = None
self._rgbw_buf_n: int = 0
# Pre-allocated send buffer (header + payload). Sized lazily on first
# send so we never allocate fresh bytes per frame on the hot path.
self._send_buf: Optional[bytearray] = None
self._send_view: Optional[memoryview] = None
self._send_buf: bytearray | None = None
self._send_view: memoryview | None = None
async def connect(self):
"""Establish UDP connection."""
@@ -13,7 +13,7 @@ from __future__ import annotations
import asyncio
import socket
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
from urllib.parse import urlparse
import numpy as np
@@ -69,7 +69,7 @@ class DDPLEDClient(LEDClient):
led_count: int = 0,
*,
rgbw: bool = False,
port: Optional[int] = None,
port: int | None = None,
destination_id: int = DEFAULT_DESTINATION_ID,
color_order: int = DEFAULT_COLOR_ORDER,
):
@@ -80,7 +80,7 @@ class DDPLEDClient(LEDClient):
self._rgbw = rgbw
self._destination_id = destination_id & 0xFF
self._color_order = color_order
self._ddp: Optional[DDPClient] = None
self._ddp: DDPClient | None = None
self._connected = False
@property
@@ -92,7 +92,7 @@ class DDPLEDClient(LEDClient):
return self._port
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
@property
@@ -151,7 +151,7 @@ class DDPLEDClient(LEDClient):
# 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:
def _as_numpy(self, pixels: List[Tuple[int, int, int]] | np.ndarray) -> np.ndarray:
if isinstance(pixels, np.ndarray):
arr = pixels
else:
@@ -164,7 +164,7 @@ class DDPLEDClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self.is_connected:
@@ -176,7 +176,7 @@ class DDPLEDClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
if not self.is_connected or self._ddp is None:
@@ -189,7 +189,7 @@ class DDPLEDClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""DDP is connectionless UDP — health = host resolves + port reachable.
@@ -5,7 +5,7 @@ that maps the flat Device storage model to the right typed config.
"""
from dataclasses import dataclass, field
from typing import List, Literal, Optional, Union
from typing import List, Literal
@dataclass(frozen=True)
@@ -43,13 +43,13 @@ class DDPConfig(BaseDeviceConfig):
@dataclass(frozen=True)
class AdalightConfig(BaseDeviceConfig):
device_type: Literal["adalight"] = "adalight"
baud_rate: Optional[int] = None
baud_rate: int | None = None
@dataclass(frozen=True)
class AmbiLEDConfig(BaseDeviceConfig):
device_type: Literal["ambiled"] = "ambiled"
baud_rate: Optional[int] = None
baud_rate: int | None = None
@dataclass(frozen=True)
@@ -63,7 +63,7 @@ class DMXConfig(BaseDeviceConfig):
@dataclass(frozen=True)
class ESPNowConfig(BaseDeviceConfig):
device_type: Literal["espnow"] = "espnow"
baud_rate: Optional[int] = None
baud_rate: int | None = None
espnow_peer_mac: str = ""
espnow_channel: int = 1
@@ -217,29 +217,29 @@ class USBHIDConfig(BaseDeviceConfig):
device_type: Literal["usbhid"] = "usbhid"
DeviceConfig = Union[
WLEDConfig,
DDPConfig,
YeelightConfig,
WiZConfig,
LIFXConfig,
GoveeConfig,
OPCConfig,
NanoleafConfig,
AdalightConfig,
AmbiLEDConfig,
DMXConfig,
ESPNowConfig,
HueConfig,
SPIConfig,
ChromaConfig,
GameSenseConfig,
BLEConfig,
GroupConfig,
MQTTConfig,
WSConfig,
USBHIDConfig,
OpenRGBConfig,
MockConfig,
DemoConfig,
]
DeviceConfig = (
WLEDConfig
| DDPConfig
| YeelightConfig
| WiZConfig
| LIFXConfig
| GoveeConfig
| OPCConfig
| NanoleafConfig
| AdalightConfig
| AmbiLEDConfig
| DMXConfig
| ESPNowConfig
| HueConfig
| SPIConfig
| ChromaConfig
| GameSenseConfig
| BLEConfig
| GroupConfig
| MQTTConfig
| WSConfig
| USBHIDConfig
| OpenRGBConfig
| MockConfig
| DemoConfig
)
@@ -29,7 +29,7 @@ from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Dict, Optional
from typing import TYPE_CHECKING, Callable, Dict
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
@@ -74,9 +74,9 @@ class DiscoveryWatcher:
self._device_store = device_store
self._fire_event = fire_event
self._aiozc: Optional[AsyncZeroconf] = None
self._browser: Optional[AsyncServiceBrowser] = None
self._serial_task: Optional[asyncio.Task] = None
self._aiozc: AsyncZeroconf | None = None
self._browser: AsyncServiceBrowser | None = None
self._serial_task: asyncio.Task | None = None
self._running = False
self._started_at: float = 0.0
@@ -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
@@ -3,7 +3,7 @@
import asyncio
import struct
import uuid
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -40,7 +40,7 @@ class DMXClient(LEDClient):
def __init__(
self,
host: str,
port: Optional[int] = None,
port: int | None = None,
led_count: int = 1,
protocol: str = "artnet",
start_universe: int = 0,
@@ -123,7 +123,7 @@ class DMXClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self._transport:
@@ -133,7 +133,7 @@ class DMXClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
if not self._transport:
@@ -3,7 +3,7 @@
import asyncio
import struct
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -59,7 +59,7 @@ class ESPNowClient(LEDClient):
self,
url: str = "",
led_count: int = 0,
baud_rate: Optional[int] = None,
baud_rate: int | None = None,
espnow_peer_mac: str = "FF:FF:FF:FF:FF:FF",
espnow_channel: int = 1,
**kwargs,
@@ -109,7 +109,7 @@ class ESPNowClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
if not self.is_connected:
@@ -129,7 +129,7 @@ class ESPNowClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self.is_connected:
@@ -146,7 +146,7 @@ class ESPNowClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if the serial port is available without opening it."""
port, _baud = parse_serial_url(url)
@@ -4,7 +4,7 @@ import json
import os
import platform
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -19,7 +19,7 @@ GAME_DISPLAY_NAME = "LedGrab"
EVENT_NAME = "PIXEL_DATA"
def _get_gamesense_address() -> Optional[str]:
def _get_gamesense_address() -> str | None:
"""Discover the SteelSeries GameSense address from coreProps.json."""
if platform.system() == "Windows":
props_path = os.path.join(
@@ -77,7 +77,7 @@ class GameSenseClient(LEDClient):
self._gs_device_type = gamesense_device_type
self._connected = False
self._http_client = None
self._base_url: Optional[str] = None
self._base_url: str | None = None
async def connect(self) -> bool:
import httpx
@@ -174,7 +174,7 @@ class GameSenseClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self.is_connected or not self._http_client:
@@ -227,7 +227,7 @@ class GameSenseClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if SteelSeries Engine is running."""
address = url.replace("gamesense://", "").strip() if url else None
@@ -21,7 +21,7 @@ import json
import socket
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
from urllib.parse import urlparse
import numpy as np
@@ -105,8 +105,8 @@ class GoveeClient(LEDClient):
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._transport: asyncio.DatagramTransport | None = None
self._protocol: _GoveeProtocol | None = None
self._connected = False
self._next_tx_at: float = 0.0
@@ -123,7 +123,7 @@ class GoveeClient(LEDClient):
return self._connected and self._transport is not None
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
async def connect(self) -> bool:
@@ -185,7 +185,7 @@ class GoveeClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip → colorwc with the resulting RGB."""
@@ -206,7 +206,7 @@ class GoveeClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
"""Synchronous variant for the hot loop."""
@@ -248,7 +248,7 @@ class GoveeClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Send devStatus and wait briefly for a reply on port 4002.
@@ -298,7 +298,7 @@ class GoveeClient(LEDClient):
# ============================================================================
def _parse_scan_reply(raw: bytes) -> Optional[dict]:
def _parse_scan_reply(raw: bytes) -> dict | None:
"""Parse a Govee scan reply into a flat metadata dict.
Govee sends ``{"msg": {"cmd": "scan", "data": {"ip": ..., "device": ...,
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
from typing import TYPE_CHECKING, List, Tuple
import numpy as np
@@ -82,14 +82,14 @@ class GroupLEDClient(LEDClient):
return self._connected and all(c.is_connected for c, _ in self._children)
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
if self._group_mode == "sequence":
return self._total_led_count
return None # independent mode uses user-specified led_count
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self._children:
@@ -150,7 +150,7 @@ class GroupLEDClient(LEDClient):
results = await asyncio.gather(*tasks, return_exceptions=True)
return all(r is True for r in results if not isinstance(r, Exception))
async def snapshot_device_state(self) -> Optional[dict]:
async def snapshot_device_state(self) -> dict | None:
"""Snapshot all children's states."""
states = {}
for i, (client, _) in enumerate(self._children):
@@ -159,7 +159,7 @@ class GroupLEDClient(LEDClient):
states[i] = state
return states if states else None
async def restore_device_state(self, state: Optional[dict]) -> None:
async def restore_device_state(self, state: dict | None) -> None:
"""Restore all children's states."""
if not state:
return
@@ -4,7 +4,7 @@ import asyncio
import socket
import struct
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -79,7 +79,7 @@ class HueClient(LEDClient):
self._username = hue_username
self._client_key = hue_client_key
self._group_id = hue_entertainment_group_id
self._sock: Optional[socket.socket] = None
self._sock: socket.socket | None = None
self._connected = False
self._sequence = 0
self._dtls_sock = None
@@ -173,7 +173,7 @@ class HueClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
if not self._connected:
@@ -197,7 +197,7 @@ class HueClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self._connected:
@@ -210,7 +210,7 @@ class HueClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if the Hue bridge is reachable."""
bridge_ip = url.replace("hue://", "").rstrip("/")
+20 -20
View File
@@ -3,7 +3,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
import numpy as np
@@ -17,7 +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)
mqtt_manager: object | None = None # MQTTManager (avoid circular import)
@dataclass
@@ -25,16 +25,16 @@ class DeviceHealth:
"""Health check result for an LED device."""
online: bool = False
latency_ms: Optional[float] = None
last_checked: Optional[datetime] = None
latency_ms: float | None = None
last_checked: datetime | None = None
# Device-reported metadata (populated by type-specific health check)
device_name: Optional[str] = None
device_version: Optional[str] = None
device_led_count: Optional[int] = None
device_rgbw: Optional[bool] = None
device_led_type: Optional[str] = None
device_fps: Optional[int] = None
error: Optional[str] = None
device_name: str | None = None
device_version: str | None = None
device_led_count: int | None = None
device_rgbw: bool | None = None
device_led_type: str | None = None
device_fps: int | None = None
error: str | None = None
class PairingNotReady(Exception):
@@ -55,12 +55,12 @@ class DiscoveredDevice:
device_type: str
ip: str
mac: str
led_count: Optional[int]
version: Optional[str]
led_count: int | None
version: str | None
# Optional provider-specific detected protocol identifier (e.g. BLE family
# like "sp110e" / "triones" / "zengge" / "govee"). Surfaced so the UI can
# preselect the right sub-type when the user adds a discovered device.
ble_family: Optional[str] = None
ble_family: str | None = None
class LEDClient(ABC):
@@ -99,7 +99,7 @@ class LEDClient(ABC):
@abstractmethod
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Send pixel colors to the LED device (async).
@@ -127,11 +127,11 @@ class LEDClient(ABC):
raise NotImplementedError("send_pixels_fast not supported for this device type")
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
"""Actual LED count discovered after connect(). None if not available."""
return None
async def snapshot_device_state(self) -> Optional[dict]:
async def snapshot_device_state(self) -> dict | None:
"""Snapshot device state before streaming starts.
Override in subclasses that need to save/restore state around streaming.
@@ -139,7 +139,7 @@ class LEDClient(ABC):
"""
return None
async def restore_device_state(self, state: Optional[dict]) -> None:
async def restore_device_state(self, state: dict | None) -> None:
"""Restore device state after streaming stops.
Args:
@@ -152,7 +152,7 @@ class LEDClient(ABC):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check device health without a full client connection.
@@ -309,7 +309,7 @@ async def check_device_health(
device_type: str,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Factory: dispatch health check to the right provider."""
return await get_provider(device_type).check_health(url, http_client, prev_health)

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