Compare commits
11 Commits
fd46c51dba
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bdcc17799 | |||
| f591e258f7 | |||
| f6486f9b34 | |||
| 48dbdb90e9 | |||
| 003517247f | |||
| 888f8fd16e | |||
| ea7ee88490 | |||
| d38021f061 | |||
| 507e1385a6 | |||
| 907bdaf043 | |||
| 0dd8d430b9 |
@@ -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]
|
||||
|
||||
+146
-22
@@ -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>
|
||||
|
||||
+66
-48
@@ -18,6 +18,40 @@ 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)
|
||||
@@ -92,12 +126,8 @@ typed `Window` globals, and more).
|
||||
because the UI uses inline event handlers / Jinja templates.
|
||||
Mis-set CSP would break the app silently. Defer until templates can
|
||||
move to event-delegated handlers, then add a strict policy.
|
||||
- [ ] **`api/auth.py` exception specificity** — 9 `except Exception:`
|
||||
sites. Most are intentional best-effort `websocket.send_json`
|
||||
swallows (the WS is already closed or about to be), but the auth
|
||||
decision path itself could be tightened to specific types
|
||||
(`jwt.InvalidTokenError`, `OSError`) + `logger.exception` for
|
||||
observability.
|
||||
- [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
|
||||
@@ -105,58 +135,46 @@ typed `Window` globals, and more).
|
||||
|
||||
## Mechanical / code-quality (low risk, high line-count)
|
||||
|
||||
- [ ] **i18n parity** — **328** keys missing in `ru.json`, **325** missing
|
||||
in `zh.json`. Examples: `section.hide`, `filters.hsl_shift`,
|
||||
`filters.contrast`, `filters.temporal_blur`,
|
||||
`filters.audio_filter_template.desc`. Russian and Chinese users
|
||||
currently see raw keys for these. This is translation work, not
|
||||
code work.
|
||||
- [ ] **`Optional[T]` → `T | None`** (PEP 604) — large mechanical refactor
|
||||
across the codebase. Can be auto-fixed via `ruff check --fix
|
||||
--select UP007`. Worth doing once the file splits land.
|
||||
- [ ] **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 — mostly cosmetic; ~200 sites. The f-string still builds
|
||||
the message even when DEBUG is off.
|
||||
- [ ] **Remaining `(window as any)` sites** — typed `global-types.d.ts`
|
||||
is in place and new code uses `window.foo` directly, but ~80
|
||||
existing sites still have the cast. Per-site mechanical cleanup.
|
||||
Add `eslint`-equivalent guard (TS rule) to prevent new ones.
|
||||
- [ ] **Magic numbers → named constants** in processing hot paths —
|
||||
`_FILTER_RECHECK_EVERY_N_FRAMES = 30` in
|
||||
`core/processing/processed_stream.py:159`; `5 ms` / `5 s` /
|
||||
`30 iterations` literals in `wled_target_processor.py:890,893,915`.
|
||||
- [ ] **Standardise `from __future__ import annotations`** across the
|
||||
codebase. Some modules use the future-annotation form, others stick
|
||||
with `Optional[...]`. Enforce one via ruff `FA` rules.
|
||||
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
|
||||
|
||||
- [ ] **Route-level integration test** for the WLED scheme inference —
|
||||
POST `/api/v1/devices` with `{"url": "192.168.1.42",
|
||||
"device_type": "wled"}` and assert the stored device has
|
||||
`url == "http://192.168.1.42"`. The helper is exhaustively
|
||||
unit-tested but no integration test exercises the create/update
|
||||
flow end-to-end.
|
||||
- [ ] **IPv6 public address regression** — extend `test_url_scheme.py`
|
||||
with explicit assertions for `2001:db8::1` and similar public IPv6
|
||||
literals (the bare-label fallback used to misclassify these). The
|
||||
helper does the right thing today via the IPv6 probe added during
|
||||
the hardening pass, but no test pins it.
|
||||
- [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:
|
||||
|
||||
- [ ] **`icon-select.ts:_buildGrid` `item.icon` is interpolated raw** —
|
||||
documented as "trusted SVG by design". If callers ever feed
|
||||
user-supplied icon strings, that's an XSS sink. Audit every caller
|
||||
that builds `IconSelectItem.icon` from non-constant data and
|
||||
reject HTML there.
|
||||
- [ ] **`devices.py:461` `manager.update_device_info(device_url=update_data.url)`**
|
||||
receives `None` when a PATCH omits `url` (rename / icon-only edit).
|
||||
The processor never re-syncs in that case. Should pass
|
||||
`existing.url` (after normalization) or skip the call.
|
||||
- [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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -19,6 +19,19 @@ logger = get_logger(__name__)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# 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:
|
||||
"""Return True when at least one API key is configured."""
|
||||
return bool(get_config().auth.api_keys)
|
||||
@@ -181,7 +194,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
||||
)
|
||||
try:
|
||||
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -190,7 +203,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
||||
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
|
||||
@@ -254,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:
|
||||
@@ -277,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
|
||||
|
||||
@@ -286,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
|
||||
|
||||
@@ -296,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
|
||||
|
||||
@@ -313,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
|
||||
|
||||
@@ -323,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -421,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -289,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",
|
||||
),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,57 +11,53 @@ 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: Optional[str] = Field(
|
||||
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: Optional[str] = Field(
|
||||
operator: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Comparison operator for http_poll rule: "
|
||||
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
|
||||
),
|
||||
)
|
||||
value: Optional[str] = Field(
|
||||
value: str | None = Field(
|
||||
None, description="Expected value (for http_poll rule; ignored for 'exists')"
|
||||
)
|
||||
|
||||
@@ -77,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.",
|
||||
@@ -100,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.",
|
||||
@@ -132,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 SingleColorCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["single_color"] = "single_color"
|
||||
color: Any = Field(description="Solid RGB color")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
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,7 +236,7 @@ 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):
|
||||
@@ -248,25 +247,23 @@ class GameEventCSSResponse(_CSSResponseBase):
|
||||
|
||||
|
||||
ColorStripSourceResponse = Annotated[
|
||||
Union[
|
||||
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")],
|
||||
],
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -281,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.",
|
||||
@@ -301,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 SingleColorCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["single_color"] = "single_color"
|
||||
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
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)"
|
||||
)
|
||||
@@ -367,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"
|
||||
)
|
||||
|
||||
@@ -391,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
|
||||
)
|
||||
|
||||
@@ -402,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)")
|
||||
|
||||
@@ -426,49 +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: Optional[str] = Field(None, description="Game integration entity ID")
|
||||
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: Optional[List[dict]] = Field(None, description="Event-to-effect mappings")
|
||||
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[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")],
|
||||
],
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -481,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.",
|
||||
@@ -500,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 SingleColorCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["single_color"] = "single_color"
|
||||
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
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)")
|
||||
|
||||
|
||||
@@ -567,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"
|
||||
)
|
||||
|
||||
@@ -591,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
|
||||
)
|
||||
|
||||
@@ -602,73 +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: Optional[str] = Field(None, description="Game integration entity ID")
|
||||
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: Optional[List[dict]] = Field(None, description="Event-to-effect mappings")
|
||||
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[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")],
|
||||
],
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -699,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],...]"
|
||||
)
|
||||
|
||||
@@ -742,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":
|
||||
@@ -759,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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
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]")
|
||||
@@ -64,10 +63,10 @@ class HTTPEndpointCreate(BaseModel):
|
||||
)
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float = Field(default=10.0, gt=0)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
description: str | None = Field(None, max_length=500)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
icon: Optional[str] = Field(None, max_length=64)
|
||||
icon_color: Optional[str] = Field(None, max_length=32)
|
||||
icon: str | None = Field(None, max_length=64)
|
||||
icon_color: str | None = Field(None, max_length=32)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
@@ -88,16 +87,16 @@ class HTTPEndpointUpdate(BaseModel):
|
||||
field (or send ``null``) to keep it.
|
||||
"""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
url: Optional[str] = Field(None, min_length=1)
|
||||
method: Optional[Literal["GET", "HEAD"]] = None
|
||||
auth_token: Optional[str] = Field(None, description="null = keep existing; '' = clear.")
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
timeout_s: Optional[float] = Field(None, gt=0)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(None, max_length=64)
|
||||
icon_color: Optional[str] = Field(None, max_length=32)
|
||||
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
|
||||
@@ -125,10 +124,10 @@ class HTTPEndpointResponse(BaseModel):
|
||||
auth_token_set: bool = False
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
icon: Optional[str] = Field(None, max_length=64)
|
||||
icon_color: Optional[str] = Field(None, max_length=32)
|
||||
icon: str | None = Field(None, max_length=64)
|
||||
icon_color: str | None = Field(None, max_length=32)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -160,7 +159,7 @@ class HTTPTestRequest(BaseModel):
|
||||
|
||||
class HTTPTestResponse(BaseModel):
|
||||
success: bool
|
||||
status_code: Optional[int] = None
|
||||
body_preview: Optional[str] = Field(None, description="First 500 chars of the body")
|
||||
status_code: int | None = None
|
||||
body_preview: str | None = Field(None, description="First 500 chars of the body")
|
||||
body_json: Any = None
|
||||
error: Optional[str] = None
|
||||
error: str | None = None
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.1–10.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.1–10.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.1–10.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.",
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -163,22 +163,20 @@ class HTTPValueSourceResponse(_ValueSourceResponseBase):
|
||||
|
||||
|
||||
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[HTTPValueSourceResponse, Tag("http")],
|
||||
],
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -191,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.",
|
||||
@@ -276,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)"
|
||||
)
|
||||
|
||||
@@ -333,22 +331,20 @@ class HTTPValueSourceCreate(_ValueSourceCreateBase):
|
||||
|
||||
|
||||
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[HTTPValueSourceCreate, Tag("http")],
|
||||
],
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -360,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.",
|
||||
@@ -377,142 +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: Optional[str] = Field(None, description="HTTP endpoint ID")
|
||||
json_path: Optional[str] = Field(None, description="Dot-path into the response")
|
||||
interval_s: Optional[int] = Field(None, description="Polling cadence (seconds)", ge=1)
|
||||
min_value: Optional[float] = Field(None, description="Raw value mapped to 0.0")
|
||||
max_value: Optional[float] = Field(None, description="Raw value mapped to 1.0")
|
||||
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||
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[HTTPValueSourceUpdate, Tag("http")],
|
||||
],
|
||||
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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Dict, Optional, Set
|
||||
from typing import Callable, Dict, Set
|
||||
|
||||
from ledgrab.core.automations.platform_detector import PlatformDetector
|
||||
from ledgrab.storage.automation import (
|
||||
@@ -38,11 +38,11 @@ class _RuleEvalContext:
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _apply_operator(operator: str, extracted, expected: str) -> bool:
|
||||
@@ -101,7 +101,7 @@ class AutomationEngine:
|
||||
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)
|
||||
@@ -420,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(
|
||||
@@ -453,11 +453,11 @@ class AutomationEngine:
|
||||
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:
|
||||
ctx = _RuleEvalContext(
|
||||
running_procs=running_procs,
|
||||
@@ -531,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
|
||||
@@ -612,7 +612,7 @@ class AutomationEngine:
|
||||
self,
|
||||
rule: ApplicationRule,
|
||||
running_procs: Set[str],
|
||||
topmost_proc: Optional[str],
|
||||
topmost_proc: str | None,
|
||||
topmost_fullscreen: bool,
|
||||
fullscreen_procs: Set[str],
|
||||
) -> bool:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (~5–10 µ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
|
||||
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,7 +22,7 @@ import socket
|
||||
import struct
|
||||
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
|
||||
@@ -142,7 +142,7 @@ def _build_set_power_payload(on: bool, duration_ms: int = 0) -> bytes:
|
||||
return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF)
|
||||
|
||||
|
||||
def _parse_state_service_reply(raw: bytes) -> Optional[dict]:
|
||||
def _parse_state_service_reply(raw: bytes) -> dict | None:
|
||||
"""Parse a LIFX StateService (discovery) reply.
|
||||
|
||||
Returns ``{"mac": "aabbccddeeff", "service": int, "port": int}`` or
|
||||
@@ -192,8 +192,8 @@ class LIFXClient(LEDClient):
|
||||
self._port = port
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
self._protocol: Optional[_LIFXProtocol] = None
|
||||
self._transport: asyncio.DatagramTransport | None = None
|
||||
self._protocol: _LIFXProtocol | None = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
self._sequence: int = 0
|
||||
@@ -211,7 +211,7 @@ class LIFXClient(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:
|
||||
@@ -257,7 +257,7 @@ class LIFXClient(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 → HSBK → SetColor."""
|
||||
@@ -279,7 +279,7 @@ class LIFXClient(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 — same shape, runs on the hot loop."""
|
||||
@@ -318,7 +318,7 @@ class LIFXClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
"""Send GetService and wait briefly for a StateService reply."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,7 +50,7 @@ class MockClient(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:
|
||||
@@ -64,7 +64,7 @@ class MockClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
|
||||
@@ -6,7 +6,7 @@ an ``mqtt_source_id`` so different devices can talk to different brokers.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from typing import List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -81,7 +81,7 @@ class MQTTLEDClient(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 self._runtime is None:
|
||||
@@ -111,7 +111,7 @@ class MQTTLEDClient(LEDClient):
|
||||
prev_health=None,
|
||||
*,
|
||||
mqtt_manager=None,
|
||||
mqtt_source_id: Optional[str] = None,
|
||||
mqtt_source_id: str | None = None,
|
||||
) -> DeviceHealth:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from typing import List, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
@@ -137,7 +137,7 @@ class NanoleafClient(LEDClient):
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._request_timeout_s = request_timeout_s
|
||||
self._http: Optional[httpx.AsyncClient] = None
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
|
||||
@@ -156,7 +156,7 @@ class NanoleafClient(LEDClient):
|
||||
return self._connected and self._http is not None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
def device_led_count(self) -> int | None:
|
||||
return self._led_count or None
|
||||
|
||||
def _state_url(self) -> str:
|
||||
@@ -194,7 +194,7 @@ class NanoleafClient(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 and PUT a single HSB state update."""
|
||||
@@ -252,7 +252,7 @@ class NanoleafClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
"""GET ``/api/v1/<token>/info``. Without a token we can't authenticate,
|
||||
so we fall back to GET ``/api/v1`` which returns 401 when the host is
|
||||
|
||||
@@ -21,7 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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
|
||||
@@ -82,8 +82,8 @@ class OPCClient(LEDClient):
|
||||
self._led_count = led_count
|
||||
self._channel = channel & 0xFF
|
||||
self._connect_timeout_s = connect_timeout_s
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
@@ -103,7 +103,7 @@ class OPCClient(LEDClient):
|
||||
return self._connected and self._writer 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:
|
||||
@@ -144,7 +144,7 @@ class OPCClient(LEDClient):
|
||||
return np.zeros_like(pixels)
|
||||
return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8)
|
||||
|
||||
def _as_numpy(self, pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> np.ndarray:
|
||||
def _as_numpy(self, pixels: List[Tuple[int, int, int]] | np.ndarray) -> np.ndarray:
|
||||
if isinstance(pixels, np.ndarray):
|
||||
arr = pixels
|
||||
else:
|
||||
@@ -159,7 +159,7 @@ class OPCClient(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:
|
||||
@@ -175,7 +175,7 @@ class OPCClient(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 hot-path write. Drain runs implicitly when the OS buffer
|
||||
@@ -197,7 +197,7 @@ class OPCClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
"""Open a TCP connection and close it. OPC has no protocol-level
|
||||
ping; reachable TCP is the strongest signal we get."""
|
||||
|
||||
@@ -5,7 +5,7 @@ import socket
|
||||
import struct
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -24,7 +24,7 @@ def parse_openrgb_url(url: str) -> Tuple[str, int, int, List[str]]:
|
||||
When *zone_names* is non-empty, only LEDs in those zones are addressed.
|
||||
Multiple zones are separated by ``+``.
|
||||
"""
|
||||
zones_str: Optional[str] = None
|
||||
zones_str: str | None = None
|
||||
|
||||
if url.startswith("openrgb://"):
|
||||
url = url[len("openrgb://") :]
|
||||
@@ -87,16 +87,16 @@ class OpenRGBLEDClient(LEDClient):
|
||||
self._client: Any = None # openrgb.OpenRGBClient
|
||||
self._device: Any = None # openrgb.Device
|
||||
self._connected = False
|
||||
self._device_name: Optional[str] = None
|
||||
self._device_led_count: Optional[int] = None
|
||||
self._device_name: str | None = None
|
||||
self._device_led_count: int | None = None
|
||||
|
||||
# Background sender thread — decouples processing loop from blocking TCP writes
|
||||
self._send_lock = threading.Lock()
|
||||
self._send_event = threading.Event()
|
||||
self._send_pending: Optional[Tuple[np.ndarray, int]] = None # (pixels, brightness)
|
||||
self._send_thread: Optional[threading.Thread] = None
|
||||
self._send_pending: Tuple[np.ndarray, int] | None = None # (pixels, brightness)
|
||||
self._send_thread: threading.Thread | None = None
|
||||
self._send_stop = threading.Event()
|
||||
self._last_sent_pixels: Optional[np.ndarray] = None
|
||||
self._last_sent_pixels: np.ndarray | None = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to OpenRGB server and access the target device."""
|
||||
@@ -207,12 +207,12 @@ class OpenRGBLEDClient(LEDClient):
|
||||
return self._connected and self._client is not None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
def device_led_count(self) -> int | None:
|
||||
return self._device_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:
|
||||
"""Send pixel colors to the OpenRGB device (async wrapper)."""
|
||||
@@ -232,7 +232,7 @@ class OpenRGBLEDClient(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:
|
||||
"""Non-blocking fire-and-forget send for the processing hot loop.
|
||||
@@ -377,7 +377,7 @@ class OpenRGBLEDClient(LEDClient):
|
||||
finally:
|
||||
comms.lock.release()
|
||||
|
||||
async def snapshot_device_state(self) -> Optional[dict]:
|
||||
async def snapshot_device_state(self) -> dict | None:
|
||||
"""Save the active mode index before streaming."""
|
||||
if self._device is None:
|
||||
return None
|
||||
@@ -387,7 +387,7 @@ class OpenRGBLEDClient(LEDClient):
|
||||
logger.warning(f"Could not snapshot OpenRGB device state: {e}")
|
||||
return None
|
||||
|
||||
async def restore_device_state(self, state: Optional[dict]) -> None:
|
||||
async def restore_device_state(self, state: dict | None) -> None:
|
||||
"""Restore the original mode after streaming stops."""
|
||||
if not state or self._device is None:
|
||||
return
|
||||
@@ -404,7 +404,7 @@ class OpenRGBLEDClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check OpenRGB server reachability via raw TCP socket connect.
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ zones individually, so it doesn't reduce.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Tuple, Union
|
||||
from typing import List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def average_color(
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
pixels: List[Tuple[int, int, int]] | np.ndarray,
|
||||
) -> Tuple[int, int, int]:
|
||||
"""Reduce an N-pixel strip to one average RGB triple.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ desktop COM ports on desktop, USB devices on Android.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Protocol
|
||||
from typing import List, Protocol
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.platform import is_android
|
||||
@@ -119,7 +119,7 @@ def port_exists(device: str) -> bool:
|
||||
|
||||
def open_transport(
|
||||
url: str,
|
||||
baud_rate: Optional[int] = None,
|
||||
baud_rate: int | None = None,
|
||||
timeout: float = 1.0,
|
||||
) -> SerialTransport:
|
||||
"""Construct an unopened transport for ``url``. Caller invokes ``open()``."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -155,7 +155,7 @@ class SPIClient(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:
|
||||
@@ -205,7 +205,7 @@ class SPIClient(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:
|
||||
@@ -222,7 +222,7 @@ class SPIClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check if the SPI/GPIO device is accessible."""
|
||||
import platform
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,7 +90,7 @@ class USBHIDClient(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:
|
||||
@@ -144,7 +144,7 @@ class USBHIDClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check if the HID device is present."""
|
||||
try:
|
||||
|
||||
@@ -17,7 +17,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
|
||||
@@ -82,8 +82,8 @@ class WiZClient(LEDClient):
|
||||
self._port = port
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
self._protocol: Optional[_WiZProtocol] = None
|
||||
self._transport: asyncio.DatagramTransport | None = None
|
||||
self._protocol: _WiZProtocol | None = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
|
||||
@@ -100,7 +100,7 @@ class WiZClient(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:
|
||||
@@ -137,7 +137,7 @@ class WiZClient(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 pixel strip to one color and push ``setPilot``."""
|
||||
@@ -161,7 +161,7 @@ class WiZClient(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 path. Same shape as send_pixels."""
|
||||
@@ -205,7 +205,7 @@ class WiZClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
"""Send a getPilot and wait briefly for any reply on a one-shot socket."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
from typing import List, Tuple, Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
@@ -106,10 +106,10 @@ class WLEDClient(LEDClient):
|
||||
parsed = urlparse(self.url)
|
||||
self.host = parsed.hostname or parsed.netloc.split(":")[0]
|
||||
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._ddp_client: Optional[DDPClient] = None
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._ddp_client: DDPClient | None = None
|
||||
self._connected = False
|
||||
self._pre_connect_state: Optional[dict] = None
|
||||
self._pre_connect_state: dict | None = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Establish connection to WLED device.
|
||||
@@ -208,7 +208,7 @@ class WLEDClient(LEDClient):
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
json_data: Dict[str, Any] | None = None,
|
||||
retry: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make HTTP request to WLED device with retry logic.
|
||||
@@ -498,7 +498,7 @@ class WLEDClient(LEDClient):
|
||||
|
||||
# ===== LEDClient abstraction methods =====
|
||||
|
||||
async def snapshot_device_state(self) -> Optional[dict]:
|
||||
async def snapshot_device_state(self) -> dict | None:
|
||||
"""Snapshot WLED state (on, lor, AudioReactive).
|
||||
|
||||
If connect() already captured a pre-mutation snapshot, returns that
|
||||
@@ -525,7 +525,7 @@ class WLEDClient(LEDClient):
|
||||
logger.warning(f"Could not snapshot WLED state: {e}")
|
||||
return None
|
||||
|
||||
async def restore_device_state(self, state: Optional[dict]) -> None:
|
||||
async def restore_device_state(self, state: dict | None) -> None:
|
||||
"""Restore WLED state after streaming."""
|
||||
if not state:
|
||||
return
|
||||
@@ -541,7 +541,7 @@ class WLEDClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
"""WLED health check via GET /json/info (+ /json/cfg for LED type)."""
|
||||
url = url.rstrip("/")
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Dict, List, Tuple
|
||||
|
||||
import httpx
|
||||
from zeroconf import ServiceStateChange
|
||||
@@ -47,7 +47,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for WLED LED controllers."""
|
||||
|
||||
def __init__(self):
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
self._client_lock = asyncio.Lock()
|
||||
# Per-base-URL state cache: base -> (expires_at, json_state_dict)
|
||||
self._state_cache: Dict[str, tuple] = {}
|
||||
@@ -186,7 +186,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
|
||||
async def _enrich_device(
|
||||
self, url: str, fallback_name: str
|
||||
) -> tuple[str, Optional[str], Optional[int], str]:
|
||||
) -> tuple[str, str | None, int | None, str]:
|
||||
"""Probe a WLED device's /json/info to get name, version, LED count, MAC.
|
||||
|
||||
Reuses the shared HTTP client so discovery probes share connection-pool state.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -85,7 +85,7 @@ class WSLEDClient(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:
|
||||
@@ -123,7 +123,7 @@ class WSLEDClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
|
||||
@@ -22,7 +22,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
|
||||
@@ -77,8 +77,8 @@ class YeelightClient(LEDClient):
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._connect_timeout_s = connect_timeout_s
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
self._req_id: int = 0
|
||||
@@ -93,7 +93,7 @@ class YeelightClient(LEDClient):
|
||||
return self._connected and self._writer 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:
|
||||
@@ -141,7 +141,7 @@ class YeelightClient(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 pixel strip to one color and ``set_rgb``.
|
||||
@@ -185,7 +185,7 @@ class YeelightClient(LEDClient):
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
prev_health: DeviceHealth | None = None,
|
||||
) -> DeviceHealth:
|
||||
"""Health check: open the TCP socket to the bulb and close it."""
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -230,7 +230,7 @@ _DISCOVER_REQUEST = (
|
||||
).encode("ascii")
|
||||
|
||||
|
||||
def _parse_ssdp_response(raw: bytes) -> Optional[dict]:
|
||||
def _parse_ssdp_response(raw: bytes) -> dict | None:
|
||||
"""Parse a Yeelight discovery response into a ``{header: value}`` dict.
|
||||
|
||||
Returns ``None`` when the payload doesn't look like a Yeelight reply
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Auto-crop postprocessing filter."""
|
||||
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -52,7 +52,7 @@ class AutoCropFilter(PostprocessingFilter):
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
|
||||
threshold = self.options.get("threshold", 15)
|
||||
min_bar_size = self.options.get("min_bar_size", 20)
|
||||
min_aspect_ratio = float(self.options.get("min_aspect_ratio", 0.0))
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -23,8 +23,8 @@ class FilterOptionDef:
|
||||
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 = {
|
||||
@@ -76,7 +76,7 @@ class PostprocessingFilter(ABC):
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def process_image(self, image: np.ndarray, image_pool: "ImagePool") -> Optional[np.ndarray]:
|
||||
def process_image(self, image: np.ndarray, image_pool: "ImagePool") -> np.ndarray | None:
|
||||
"""Process image.
|
||||
|
||||
Args:
|
||||
@@ -89,7 +89,7 @@ class PostprocessingFilter(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
|
||||
def process_strip(self, strip: np.ndarray) -> np.ndarray | None:
|
||||
"""Process a 1D LED strip array (N, 3) uint8.
|
||||
|
||||
Default implementation reshapes to (1, N, 3), calls process_image
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Brightness postprocessing filter."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -36,7 +36,7 @@ class BrightnessFilter(PostprocessingFilter):
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
|
||||
if self.options["value"] == 1.0:
|
||||
return None
|
||||
image[:] = self._lut[image]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Color correction postprocessing filter."""
|
||||
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -118,7 +118,7 @@ class ColorCorrectionFilter(PostprocessingFilter):
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
|
||||
if self._is_neutral:
|
||||
return None
|
||||
image[:] = self._lut[image]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user