Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bdcc17799 | |||
| f591e258f7 | |||
| f6486f9b34 | |||
| 48dbdb90e9 | |||
| 003517247f | |||
| 888f8fd16e | |||
| ea7ee88490 | |||
| d38021f061 | |||
| 507e1385a6 | |||
| 907bdaf043 | |||
| 0dd8d430b9 | |||
| fd46c51dba | |||
| ddae5719cf | |||
| 898912f8b1 | |||
| 45d12b2811 | |||
| 826e680f37 | |||
| 737fd72b73 | |||
| 3fe66d80cb | |||
| f03cb303c3 | |||
| 9ff83bd6ca | |||
| d6cc80074d | |||
| 06273ba2bc | |||
| 628c6b2f0d | |||
| 2f15fbb752 | |||
| c1aa2ebec5 | |||
| 3b8f00e3f9 | |||
| 05f73eedf9 | |||
| 9f3f346543 | |||
| 98fb61d932 | |||
| 5fec8db901 | |||
| 97dae2cd62 | |||
| 29bdacf69a | |||
| 563cbac88c | |||
| e24f9d33cc | |||
| e4bf58da19 | |||
| f1b0f0eab2 | |||
| 17684afba1 | |||
| 0e3ae78de7 | |||
| 7736bc6f58 | |||
| 390d2b472c | |||
| cc87fba0dd | |||
| 426484adf8 | |||
| 2f31680823 | |||
| 31c6c3abb2 | |||
| 887131d4af | |||
| 8f9d490063 | |||
| ede627b4ac | |||
| 4b65005823 | |||
| 8f1140abad | |||
| 337984c618 | |||
| 530316c2c3 | |||
| 6e4c1b6642 | |||
| ee4fa81376 | |||
| f184ef0afb | |||
| ad84b60ae4 | |||
| cdf7d94652 | |||
| 09792a9a05 | |||
| 75ca487be1 | |||
| e65dcb41f4 | |||
| 6a07a6b1a2 | |||
| 0f5850ef80 | |||
| a79f4bf73c | |||
| ced72fc864 | |||
| 49ddabbc36 | |||
| a026f0b349 | |||
| 5ef6ac1317 | |||
| 0980cf4dde | |||
| fdac26b9d9 | |||
| 816a27db73 | |||
| 797b806972 | |||
| 9d4a534ec6 | |||
| 51eebf21d5 | |||
| 9067db2639 | |||
| 233b463ac3 | |||
| de13f44f24 | |||
| 1c9acc5afb | |||
| a56569b02f | |||
| ccf4406349 | |||
| 8aa3a323d6 | |||
| 8e109f32b9 | |||
| 033c1f6a92 | |||
| 0804f54537 | |||
| 66f921c07f | |||
| 80f01d4813 | |||
| b1ee3c3942 | |||
| e0ff40f4f5 | |||
| 3f80ef2101 | |||
| 2bae304107 | |||
| dd415e2813 | |||
| b43e1cf375 | |||
| 56853b7123 | |||
| 70c95d1c09 | |||
| e5a2af9821 | |||
| 539e43195f |
@@ -54,6 +54,17 @@ jobs:
|
||||
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
|
||||
echo "Build label: $LABEL (release=$IS_RELEASE)"
|
||||
|
||||
- name: Guard release tag against missing keystore
|
||||
# Release tags MUST produce a release-signed APK, otherwise existing
|
||||
# installs can't upgrade (signature mismatch). Fail loudly instead
|
||||
# of silently falling back to the debug signing config.
|
||||
# Runs before JDK/Python/SDK/NDK setup so a misconfigured release
|
||||
# tag fails in seconds instead of after several minutes of setup.
|
||||
if: ${{ steps.label.outputs.is_release == 'true' && env.ANDROID_KEYSTORE_BASE64 == '' }}
|
||||
run: |
|
||||
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
|
||||
exit 1
|
||||
|
||||
- name: Setup JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
@@ -122,15 +133,6 @@ jobs:
|
||||
echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT"
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Guard release tag against missing keystore
|
||||
# Release tags MUST produce a release-signed APK, otherwise existing
|
||||
# installs can't upgrade (signature mismatch). Fail loudly instead
|
||||
# of silently falling back to the debug signing config.
|
||||
if: ${{ steps.label.outputs.is_release == 'true' && steps.keystore.outputs.present != 'true' }}
|
||||
run: |
|
||||
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
|
||||
exit 1
|
||||
|
||||
- name: Build APK
|
||||
working-directory: android
|
||||
env:
|
||||
|
||||
@@ -5,9 +5,15 @@ on:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
# Allow manual runs (e.g. to validate after a release commit was skipped).
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# Skip release-publishing commits — version bumps don't affect lint/tests
|
||||
# and the release.yml pipeline is already running. PRs and manual dispatch
|
||||
# always run.
|
||||
if: ${{ github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore: release') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
+10
@@ -68,6 +68,11 @@ logs/
|
||||
# shipped sound assets out of the CI tag checkout.
|
||||
/data/
|
||||
/server/data/
|
||||
# Defensive: if the server is launched from server/src/ (uncommon path),
|
||||
# its relative `data/` dir resolves to server/src/data/. Templates now
|
||||
# live in SQLite, so any *.json that lands here is stale runtime export
|
||||
# and must not be committed.
|
||||
/server/src/data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.json.bak
|
||||
@@ -90,3 +95,8 @@ tmp/
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
# vex semantic-search embedding cache (auto-downloaded on first --semantic run)
|
||||
.fastembed_cache/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# vex configuration — https://github.com/tenatarika/vex
|
||||
#
|
||||
# Place this file in your project root as .vex.toml
|
||||
|
||||
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
|
||||
# exclude = [
|
||||
# "vendor/**",
|
||||
# "node_modules/**",
|
||||
# "*.generated.go",
|
||||
# "dist/**",
|
||||
# ]
|
||||
|
||||
# Default output format: "text", "json", or "compact"
|
||||
# format = "text"
|
||||
|
||||
# Enable semantic embeddings by default (slower indexing, enables meaning-based search)
|
||||
semantic = true
|
||||
|
||||
# Automatically run `vex update` before search if the index is stale
|
||||
auto_update = true
|
||||
|
||||
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
|
||||
# Changing the embedder requires a full reindex.
|
||||
# embedder = "minilm-l6-v2"
|
||||
@@ -0,0 +1,347 @@
|
||||
# LedGrab Architecture Audit — Remaining Items
|
||||
|
||||
Roadmap for the architecture-audit refactor sprint that started 2026-05-22.
|
||||
This file lists every audit finding that is **not yet addressed**; the ones
|
||||
already landed in commits `563cbac..2f15fbb` are summarised below for
|
||||
context.
|
||||
|
||||
## Already done (10 commits)
|
||||
|
||||
| Commit | Findings addressed |
|
||||
|---|---|
|
||||
| `563cbac` | C2, C11, C1 (parallel-change only), C3, C4, C6, C7-streams |
|
||||
| `29bdacf` | C5 (HA/Z2M swap helper; full ABC deferred) |
|
||||
| `97dae2c` | H1 |
|
||||
| `5fec8db` | M4 |
|
||||
| `98fb61d` | H2 |
|
||||
| `9f3f346` | M5 |
|
||||
| `05f73ee` | H6 (bindable extraction only) |
|
||||
| `3b8f00e` + `c1aa2eb` | C7 store-side |
|
||||
| `2f15fbb` | H3 |
|
||||
|
||||
All commits have ≥1 code-review subagent pass with HIGH findings fixed
|
||||
before commit. Tests pass on each commit; ruff clean; tsc + bundle build
|
||||
clean for the frontend commit.
|
||||
|
||||
The two CRITICAL **data-safety** items (C2 silent CSS fallback, C11
|
||||
string-replace JSON migration) are fixed. The two CRITICAL
|
||||
**parallel-change** problems for color-strip + value-source dispatch are
|
||||
fixed. The two HIGH dispatch problems (H1 effects, H2 rules) are fixed.
|
||||
|
||||
---
|
||||
|
||||
## Remaining backend items
|
||||
|
||||
### HIGH
|
||||
|
||||
#### H4 — `Device.__init__` 40+ params mixing per-type fields
|
||||
|
||||
**File:** `server/src/ledgrab/storage/device_store.py:46-150`
|
||||
|
||||
The `Device` dataclass constructor accepts ~40 parameters that mix common
|
||||
fields with DMX-only / DDP-only / Hue-only / Yeelight-only / Wiz-only /
|
||||
LIFX-only / Govee-only / Nanoleaf-only / SPI-only / Chroma-only /
|
||||
GameSense-only fields. Setting `hue_username` on a WLED device is
|
||||
silently ignored.
|
||||
|
||||
**Approach:** introduce per-device-type config dataclasses
|
||||
(`DmxConfig`, `HueConfig`, `DdpConfig`, …) and make `Device.config` a
|
||||
discriminated union. Per-type validation moves to the config classes.
|
||||
Wire migration: every existing device row needs to be re-parsed; use the
|
||||
versioned `MigrationRunner` introduced in Phase 1.2.
|
||||
|
||||
**Risk:** medium-high. Touches:
|
||||
- `storage/device_store.py` — Device dataclass, `from_dict`, `to_dict`,
|
||||
`create_device`, `update_device`
|
||||
- `api/schemas/devices.py` — Pydantic schemas
|
||||
- `api/routes/devices.py` — request validation
|
||||
- `core/devices/*` — every provider reads device fields
|
||||
- A new migration to translate flat fields → nested `config`
|
||||
|
||||
**Estimated scope:** ~1500 LOC diff, 1-2 dedicated sessions.
|
||||
|
||||
#### H5 — `WledTargetProcessor` god class (32 methods, 5 responsibilities)
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/wled_target_processor.py` (1238 LOC)
|
||||
|
||||
Conflates:
|
||||
1. Device connectivity (probe, liveness, reconnect)
|
||||
2. FPS negotiation (adaptive_fps, keepalive_interval, state_check_interval)
|
||||
3. LED resampling (`_fit_to_device` — 60 lines of numpy)
|
||||
4. Preview WebSocket fanout (`_preview_clients`, `_broadcast_led_preview`)
|
||||
5. Metrics emission (`get_state`, `get_metrics`)
|
||||
|
||||
**Approach:** extract `WledDeviceConnector`, `WledPixelSender`,
|
||||
`TargetFitProcessor`, `TargetPreviewBroadcaster`, `TargetMetricsCollector`.
|
||||
`WledTargetProcessor` becomes an orchestrator that composes them.
|
||||
|
||||
**Risk:** HIGHEST in the audit. This class drives physical LED hardware
|
||||
in production. A regression caught at runtime (in the user's living
|
||||
room) is the expensive failure mode. Needs manual verification with at
|
||||
least one real WLED device after the refactor.
|
||||
|
||||
**Coupled with:** C5 (HA/Z2M shared the same shape; should extract a
|
||||
common `BaseTargetProcessor` ABC at the same time so all three
|
||||
processors share lifecycle / preview / metrics code).
|
||||
|
||||
**Estimated scope:** ~2000 LOC diff, 2-3 dedicated sessions, with manual
|
||||
device testing after each.
|
||||
|
||||
#### H7 — `device-discovery.ts` 1745 LOC
|
||||
|
||||
Frontend mirror of H4. The `onDeviceTypeChanged` handler has a giant
|
||||
switch with 15+ device kinds and 15+ `_showXxxFields` / `_buildXxxItems`
|
||||
helpers. Adding a device type requires editing 5 separate frontend hooks.
|
||||
|
||||
**Approach:** mirror the H4 backend redesign — once the storage layer
|
||||
has per-type config objects, the frontend can have a per-type field-set
|
||||
registry. Best done **after** H4 lands so the schemas drive the
|
||||
registry.
|
||||
|
||||
**Estimated scope:** 1-2 sessions; coupled to H4.
|
||||
|
||||
#### H8 — `automations.ts` 1410 LOC
|
||||
|
||||
Frontend mirror of H2 (rule polymorphism). Already addressed on the
|
||||
backend in `98fb61d`; the frontend dispatch on `RuleType` is still
|
||||
hand-rolled.
|
||||
|
||||
**Approach:** introduce a rule-type registry on the frontend matching
|
||||
the backend's `_RULE_HANDLERS` shape.
|
||||
|
||||
**Estimated scope:** half a session.
|
||||
|
||||
### MEDIUM
|
||||
|
||||
#### M1 — `ProcessorManager.add_target` shotgun (11 args, WLED-leak)
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/processor_manager.py:396`
|
||||
|
||||
Method is named generically (`add_target`) but accepts `protocol="ddp"`
|
||||
and `keepalive_interval` — WLED-only fields. HA and Z2M have sibling
|
||||
methods with their own bespoke params.
|
||||
|
||||
**Approach:** extract a `TargetFactory` (per-kind builders, similar to
|
||||
`value_source_factories.py` from Phase 7). Couple with H5/C5 work.
|
||||
|
||||
#### M2 — `TargetContext` god-bag
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/processor_manager.py`
|
||||
|
||||
`@dataclass TargetContext` exposes ~8 attributes (device_store,
|
||||
color_strip_stream_manager, value_stream_manager, metrics_history,
|
||||
mqtt_manager, ha_manager, …). Processors silently depend on whichever
|
||||
fields they read. Tests have to construct a huge mock context.
|
||||
|
||||
**Approach:** make per-processor explicit dependency injection. Couple
|
||||
with H5 work.
|
||||
|
||||
#### M3 — Validation duplicated across layers
|
||||
|
||||
Field-level constraints (composite nesting depth, name uniqueness, span
|
||||
ranges) are enforced in route + schema + store. Adding a new constraint
|
||||
means editing 3 places.
|
||||
|
||||
**Approach:** move all validation to the model/schema layer (Pydantic
|
||||
validators + dataclass `__post_init__`). Routes trust the schema; store
|
||||
trusts the model.
|
||||
|
||||
**Risk:** moderate — cross-cutting; needs careful review of which layer
|
||||
currently owns which constraint.
|
||||
|
||||
#### M6 — `ws_stream.py` mixed concerns (699 LOC)
|
||||
|
||||
**File:** `server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py`
|
||||
|
||||
The worst part (stream-creation dispatch) was fixed in Phase 2.1 — it
|
||||
now calls `color_strip_kinds.build_stream(source, deps)`. The remaining
|
||||
699 lines mix config parsing + WebSocket lifecycle + frame loop. Could
|
||||
extract the frame loop into a separate `PreviewFrameLoop` class.
|
||||
|
||||
**Estimated scope:** half a session. Low impact since the parallel-change
|
||||
problem is already fixed.
|
||||
|
||||
#### M7 — No shared frontend API client
|
||||
|
||||
**File:** every `static/js/features/*.ts`
|
||||
|
||||
`fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every
|
||||
feature's save / load function. ~25 files.
|
||||
|
||||
**Approach:** introduce `static/js/core/api-client.ts` with typed
|
||||
methods (`get`, `post`, `put`, `delete`) that handle auth, JSON parsing,
|
||||
error normalisation. Replace `fetchWithAuth` calls across features.
|
||||
|
||||
#### M8 — Global `_cached*` `let` vars
|
||||
|
||||
Mutable module-level state mutated from multiple feature modules. No
|
||||
subscription model — features manually `invalidate()` after CRUD.
|
||||
|
||||
**Approach:** introduce a reactive cache (EventEmitter pattern or a tiny
|
||||
store like Nano Stores). Couple with M7 (the API client can drive cache
|
||||
invalidation on write).
|
||||
|
||||
#### M9 — `dashboard.ts` 1421 LOC
|
||||
|
||||
Frontend god-module orchestrating + rendering device / target / CSS
|
||||
cards. Couple with C8/C9/C10 frontend split work.
|
||||
|
||||
#### M10 — Duplicate frontend modal classes
|
||||
|
||||
`ValueSourceModal`, `StreamEditorModal`, `TargetEditorModal`,
|
||||
`AddDeviceModal`, etc. each reimplement pristine-check / undo / focus
|
||||
management.
|
||||
|
||||
**Approach:** introduce a `FormModal<T>` base class.
|
||||
|
||||
#### M11 — Hardcoded `_getSectionForSource` / `_getTabForSource`
|
||||
|
||||
Routing tables duplicated across multiple feature files (streams.ts,
|
||||
value-sources.ts). Adding a new stream type requires hunting strings.
|
||||
|
||||
**Approach:** single routing registry keyed by source_type.
|
||||
|
||||
#### M12 — Late imports masking cycles
|
||||
|
||||
Partially addressed by the kind registries (Phase 2.1, 2.2). Some
|
||||
late-imports still exist in `value_stream.py`, `audio_stream.py`, the
|
||||
target processors. Resolving them requires restructuring module layout
|
||||
to break the circular dependencies.
|
||||
|
||||
**Estimated scope:** small follow-up after H5.
|
||||
|
||||
### LOW
|
||||
|
||||
#### L1 — `(src as any).field` casts in `value-sources.ts`
|
||||
|
||||
Discriminated unions aren't narrowed properly. Couple with C8 frontend
|
||||
split.
|
||||
|
||||
#### L2 — Mutable state without locks
|
||||
|
||||
`_preview_clients`, `_last_preview_data`, `_color_stream`,
|
||||
`_css_stream` are mutated from multiple async tasks without explicit
|
||||
locks. Production has not exhibited issues but the contract is fragile.
|
||||
|
||||
**Approach:** add explicit `asyncio.Lock` per processor. Couple with H5.
|
||||
|
||||
#### L3 — `Calibration.validate()` raises instead of returning result
|
||||
|
||||
**File:** `server/src/ledgrab/core/capture/calibration.py:164`
|
||||
|
||||
All 4 call sites currently rely on the raise; converting to
|
||||
`ValidationResult` would force every caller to check a return value
|
||||
without adding safety. **Recommendation:** skip — current design is
|
||||
appropriate.
|
||||
|
||||
#### L4 — `_SOURCE_TYPE_MAP` is module-private
|
||||
|
||||
No public `GET /api/v1/source-types` discovery endpoint. Frontend
|
||||
hardcodes the list of source types in `types.ts`.
|
||||
|
||||
**Approach:** add a discovery route + matching frontend fetch. Couple
|
||||
with H6 frontend split (since `types.ts` is involved).
|
||||
|
||||
#### L5 — `AudioValueStream` implicit state machine
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/value_stream.py:169-383`
|
||||
|
||||
`get_value()` can be called before `start()`; transitions are implicit.
|
||||
**Approach:** explicit State pattern. Low value (production callers
|
||||
always start before reading).
|
||||
|
||||
---
|
||||
|
||||
## Remaining frontend items (all)
|
||||
|
||||
### CRITICAL
|
||||
|
||||
- **C8** — `value-sources.ts` 1972 LOC (4 god-functions, type-dispatch ladders)
|
||||
- **C9** — `graph-editor.ts` 2707 LOC (layout + interaction + state + WS sync + …)
|
||||
- **C10** — `streams.ts` 2341 LOC (picture / audio / template kitchen-sink)
|
||||
|
||||
### Other frontend (severity in main list above)
|
||||
|
||||
- **H6 rest** — split remaining ~1100 LOC of `types.ts` into per-entity files
|
||||
- **H7** — `device-discovery.ts` 1745 LOC (couple with H4)
|
||||
- **H8** — `automations.ts` 1410 LOC (mirror H2)
|
||||
- **M7** — shared API client
|
||||
- **M8** — reactive cache
|
||||
- **M9** — `dashboard.ts` 1421 LOC
|
||||
- **M10** — `FormModal<T>` base
|
||||
- **M11** — routing registry
|
||||
- **L1** — narrowing the discriminated unions
|
||||
|
||||
The frontend remainder is **multi-day work** even when broken up by
|
||||
finding. Recommended approach: a dedicated frontend sprint with the
|
||||
typescript-reviewer agent + manual UI testing for each god-module
|
||||
split. Order:
|
||||
|
||||
1. Finish `types.ts` split (H6) — pure organisation, low risk, unblocks
|
||||
the rest
|
||||
2. Introduce API client (M7) — every feature file gains a cleaner shape
|
||||
3. Split `value-sources.ts` (C8) — uses the API client + per-type
|
||||
registry pattern
|
||||
4. Split `streams.ts` (C10)
|
||||
5. Split `graph-editor.ts` (C9) — needs the most care; the file owns
|
||||
the entire visual editor
|
||||
6. Polish: `dashboard.ts` (M9), `device-discovery.ts` (H7),
|
||||
`automations.ts` (H8), `FormModal` (M10), routing registry (M11),
|
||||
reactive cache (M8), narrowing (L1)
|
||||
|
||||
---
|
||||
|
||||
## Recommended ordering for future sessions
|
||||
|
||||
### Session A — Frontend sprint (multi-day)
|
||||
|
||||
Address H6-rest, C8, C9, C10, H7, H8, M7-M11, L1. See order above.
|
||||
Critical to have typescript-reviewer feedback + manual UI testing after
|
||||
each split.
|
||||
|
||||
### Session B — Device redesign (1-2 sessions)
|
||||
|
||||
Address H4 alone. Touches device storage + provider classes; needs a
|
||||
data migration. Once H4 lands, H7 frontend mirror can follow.
|
||||
|
||||
### Session C — BaseTargetProcessor ABC (2-3 sessions)
|
||||
|
||||
Address C5 (full) + H5 + M1 + M2 + L2 together. Highest risk in the
|
||||
audit because it drives physical LED hardware. Each step needs manual
|
||||
verification with a real device.
|
||||
|
||||
### Session D — Polish (half a session)
|
||||
|
||||
Address M3, M6 (remainder), M12 (remainder), L3 (decision: skip), L4,
|
||||
L5.
|
||||
|
||||
---
|
||||
|
||||
## Pattern reference for new contributors
|
||||
|
||||
Three registry-pattern templates that already exist in the codebase and
|
||||
should be the model for the remaining dispatch ladders:
|
||||
|
||||
1. **Class-level handler dict + import-time coverage assertion**
|
||||
- `core/processing/effect_stream.py::_RENDERERS`
|
||||
(`@_effect_renderer` decorator + `@_collect_effect_renderers`
|
||||
class decorator)
|
||||
- `core/automations/automation_engine.py::AutomationEngine._RULE_HANDLERS`
|
||||
(module-level binding after class definition)
|
||||
- `api/routes/output_targets.py::_TARGET_RESPONSE_BUILDERS`
|
||||
(response-shape dispatch keyed by storage class)
|
||||
|
||||
2. **Per-type free functions + dependency-bag dataclass**
|
||||
- `core/processing/color_strip_kinds.py` (`StreamDeps` + `STREAM_BUILDERS`)
|
||||
- `core/processing/value_kinds.py` (`ValueStreamDeps` + `STREAM_BUILDERS`)
|
||||
- `storage/value_source_factories.py` (`CREATE_BUILDERS` + `UPDATE_APPLIERS`)
|
||||
|
||||
3. **Versioned migration runner**
|
||||
- `storage/data_migrations.py` (`MigrationRunner` + `DataMigration` ABC)
|
||||
- Used for any storage rename / field-shape change in the future.
|
||||
- Audit-table contract: atomic transaction covers
|
||||
applied-check + apply + record, so partial-failure cannot leave
|
||||
data rewritten but unrecorded.
|
||||
|
||||
Adding a new feature that touches dispatch should reach for one of
|
||||
these three patterns before writing a fresh if/elif chain.
|
||||
@@ -55,10 +55,6 @@ The Android app (`android/app/build.gradle.kts`) installs the server package wit
|
||||
| [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) | Reusable CI/CD patterns: Gitea Actions, cross-build, NSIS, Docker |
|
||||
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
|
||||
|
||||
## Task Tracking via TODO.md
|
||||
|
||||
Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the TodoWrite tool** — all progress tracking goes through `TODO.md`.
|
||||
|
||||
## Documentation Lookup
|
||||
|
||||
**Use context7 MCP tools for library/framework documentation lookups** (FastAPI, OpenCV, Pydantic, yt-dlp, etc.) instead of relying on potentially outdated training data.
|
||||
@@ -104,3 +100,42 @@ Do NOT commit code that fails linting or tests. Fix the issues first.
|
||||
- Follow existing code style and patterns
|
||||
- Update documentation when changing behavior
|
||||
- Never make commits or pushes without explicit user approval
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
|
||||
+151
-15
@@ -1,31 +1,167 @@
|
||||
## v0.4.2 (2026-04-22)
|
||||
## v0.7.0 (2026-05-26)
|
||||
|
||||
### Bug Fixes
|
||||
- Ship previously-missing package assets in release artifacts — prebuilt notification sounds (`alert`, `bell`, `chime`, `ping`, `pop`) and game adapter YAMLs (`minecraft`, `rocket_league`, `valorant`). An unanchored `data/` rule in `.gitignore` was matching `server/src/ledgrab/data/`, so these files never reached the tag or CI builds. Also bump the `_FALLBACK_VERSION` literal to `0.4.2` so the Windows installer (which strips `.dist-info`) reports the correct version in the WebUI instead of `0.3.0`. Build scripts now patch this literal automatically to prevent future drift. ([5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd))
|
||||
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
|
||||
- Restyle the enhanced header locale picker as a LED-accent badge — 2-letter code in Orbitron, collapses to just the badge on narrow screens ([9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3))
|
||||
|
||||
#### 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
|
||||
|
||||
- **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
|
||||
- Publish `.sha256` sidecars alongside release assets for easier integrity verification ([03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b))
|
||||
#### Architecture audit — registry patterns everywhere
|
||||
|
||||
#### Refactoring
|
||||
- Move the Key Colors test out of the lightbox and into the `test-css-source` modal where the rest of the source-render debug tools live ([be2d5e1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be2d5e1))
|
||||
- **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))
|
||||
|
||||
#### Dedup / refactor
|
||||
|
||||
- **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 |
|
||||
|------|---------|--------|
|
||||
| [be2d5e1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be2d5e1) | refactor(color-strips): move Key Colors test from lightbox into test-css-source modal | alexei.dolgolyov |
|
||||
| [5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd) | fix(release): ship prebuilt assets and bump fallback version | alexei.dolgolyov |
|
||||
| [9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3) | feat(ui): restyle enhanced header locale picker as LED-accent badge | alexei.dolgolyov |
|
||||
| [03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b) | ci(release): publish .sha256 sidecars alongside release assets | alexei.dolgolyov |
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| [f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25) | fix(storage/database): reopen connection on lifespan restart |
|
||||
| [f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9) | perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings |
|
||||
| [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9) | docs(review-todo): check off items addressed in 2026-05-23 autonomous pass |
|
||||
| [0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172) | refactor(types): migrate (window as any) statics to typed window globals |
|
||||
| [888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd) | refactor(types): PEP-604 union sweep + UP007/UP045 enforcement |
|
||||
| [ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88) | refactor(api/auth): narrow WS exception catches + observability log |
|
||||
| [d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f) | refactor(processing): hot-path magic numbers -> named module constants |
|
||||
| [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138) | feat(ui/icon-select): defence-in-depth XSS sanitiser on icon channel |
|
||||
| [907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf) | test(url-scheme): WLED route-level integration + IPv6 regression |
|
||||
| [0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43) | fix(devices): preserve existing URL on PATCH-without-url |
|
||||
| [fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51) | docs: TODO + CLAUDE.md notes + locale keys for new features |
|
||||
| [ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571) | chore(frontend-infra): inbound-event allowlist + storage/state touch-ups |
|
||||
| [898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f) | chore(backend): MQTT/WLED/devices/capture/utils + api routes hardening |
|
||||
| [45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2) | feat(update-service): SSRF-validated redirects + restart hardening |
|
||||
| [826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680) | refactor(color-strip): rename static -> single + frontend follow-through |
|
||||
| [737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72) | feat(value-sources): extend storage + schema + UI alongside new kinds |
|
||||
| [3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8) | feat(automations): expand automation rules + UI + engine coverage |
|
||||
| [f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30) | feat(modal): closeIfPristine save-guard + per-editor adoption |
|
||||
| [9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd) | feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals |
|
||||
| [d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800) | feat(http-endpoints): introduce HTTP endpoint output target stack |
|
||||
| [06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba) | chore(tooling): vex semantic-search config + REVIEW_TODO backlog |
|
||||
| [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2) | docs: capture architecture-audit remainder for follow-up sessions |
|
||||
| [2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb) | refactor(output-targets): registry + coverage assertion for response builders |
|
||||
| [c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb) | fix(value-source): preserve store contract for game_event + error precedence |
|
||||
| [3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e) | refactor(value-source): per-type factories for create / update dispatch |
|
||||
| [05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee) | refactor(types): extract bindable primitives into types/bindable.ts (H6 partial) |
|
||||
| [9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346) | refactor(value-source): MetricSpec registry for SystemMetricsValueStream |
|
||||
| [98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d) | refactor(automations): rule dispatch via class-level handler table |
|
||||
| [5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db) | refactor(capture): lift duplicated edge-to-LED kernels into shared module |
|
||||
| [97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c) | refactor(processing): replace inline effect dispatch with @_effect_renderer registry |
|
||||
| [29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf) | refactor(processing): dedupe HA/Z2M _swap_color_source via shared helper |
|
||||
| [563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac) | refactor(storage,processing): kind registries + versioned data migrations |
|
||||
| [e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3) | fix(shutdown): survive PC restart with WAL fsync + Win32 session-end guard |
|
||||
| [e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d) | fix(dashboard): stop showing perpetual MODIFIED for un-edited legacy layouts |
|
||||
| [f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e) | fix(ui): repaint transport-bar uptime as soon as /health responds |
|
||||
| [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af) | docs: record review-fix pass in TODO.md |
|
||||
| [0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78) | fix(devices): address pre-merge review findings |
|
||||
| [7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6) | fix(utils): commit url_scheme + net_classify dependencies |
|
||||
| [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4) | docs: mark expand-device-support branch ready for merge |
|
||||
| [cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba) | refactor(devices): extract _average_color to pixel_reduce |
|
||||
| [426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a) | feat(devices): Nanoleaf OpenAPI target type + first pair-flow user |
|
||||
| [2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680) | feat(devices): pairing-UX scaffold (Phase 2) |
|
||||
| [31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a) | feat(devices): Open Pixel Control (OPC) target type |
|
||||
| [887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d) | feat(devices): Govee LAN target type |
|
||||
| [8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490) | feat(devices): LIFX LAN target type |
|
||||
| [ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b) | feat(devices): WiZ Connected LAN target type |
|
||||
| [4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005) | feat(devices): Yeelight LAN target type |
|
||||
| [8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a) | feat(devices): standalone DDP target type |
|
||||
| [337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c) | feat(color-strips): in-editor live preview for all viable source types |
|
||||
| [530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c) | feat(mqtt): multi-broker MQTT + Zigbee2MQTT light target |
|
||||
| [6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6) | perf(wled): cache per-frame max-pixel for brightness threshold |
|
||||
| [ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81) | perf(processing): event-driven frame hand-off and scheduling fixes |
|
||||
| [f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0) | perf(capture): vectorize hot paths and fix engine bugs |
|
||||
| [ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60) | fix(ha-light): apply brightness_scale once and respect boost multipliers |
|
||||
| [cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94) | feat(ui): expand card icon picker (44 -> 120 icons, +5 categories) |
|
||||
|
||||
</details>
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# Production Review — Remaining Items
|
||||
|
||||
Output of the multi-agent production review (security / Python / TypeScript /
|
||||
performance / architecture / code-quality). Each entry below is something
|
||||
the original audit flagged and the autonomous hardening pass deliberately
|
||||
did **not** address — either because it needs design input, profiling
|
||||
validation, or a multi-day refactor that should land in its own session.
|
||||
|
||||
The hardening pass landed everything else: see git log between `master` and
|
||||
the head of the review branch for the applied changes (URL-scheme +
|
||||
malicious-input rejection, IconSelect XSS escape, MiniSelect for forbidden
|
||||
plain `<select>`s, WebSocket Origin allow-list, /docs auth-gate, security
|
||||
headers middleware, streaming upload size caps, fire-and-forget task
|
||||
tracking + drain resilience in MQTT runtime, discovery_watcher task
|
||||
tracking, asyncio.gather return_exceptions, secret_box encryption for MQTT
|
||||
/ Hue / Govee credentials with auto-migration, SSRF-validated update
|
||||
redirects, single source of truth for IP classification in
|
||||
`utils/net_classify.py`, allowlist + parity test for inbound WS events,
|
||||
typed `Window` globals, and more).
|
||||
|
||||
## Items completed in the follow-up autonomous pass (2026-05-23)
|
||||
|
||||
- [x] **devices.py PATCH-without-url processor desync** — `update_device`
|
||||
now falls back to `existing.url` so a rename / icon-only edit
|
||||
always tells the processor the current address.
|
||||
- [x] **WLED scheme integration test** on `/api/v1/devices` — covers
|
||||
bare IPv4 (`http://`), public hostname (`https://`), and trailing-slash
|
||||
normalisation; lives in `tests/api/routes/test_devices_routes.py`.
|
||||
- [x] **IPv6 regression test** — `tests/test_url_scheme.py` now pins
|
||||
public IPv6 → `https://`, ULA → `http://`, and documents the
|
||||
Python-`ipaddress` documentation-prefix classification quirk.
|
||||
- [x] **IconSelect XSS audit + defence-in-depth** — every caller
|
||||
audited (all feed `icon` from constants or lookup tables); added
|
||||
`sanitiseIcon` that rejects `<script>`, `javascript:`, `on*=`,
|
||||
`<iframe>`, `<embed>`, `<object>` and warns to the console.
|
||||
- [x] **`Optional[T]` → `T | None` (PEP 604)** — 55 sites cleaned via
|
||||
`ruff --fix UP007`. The remaining `Union[…]` aliases for
|
||||
pixel/colour/device-config typing converted by hand. `UP007` now
|
||||
lives in `pyproject.toml` so the rule fires on new code.
|
||||
- [x] **Hot-path magic numbers → named constants** — `processed_stream`
|
||||
gains `_FILTER_RECHECK_EVERY_N_FRAMES`; `wled_target_processor`
|
||||
gains `_SKIP_REPOLL_SLEEP_SECONDS`, `_DIAGNOSTICS_REPORT_INTERVAL_SECONDS`,
|
||||
`_CSPT_RECHECK_EVERY_N_ITERATIONS`.
|
||||
- [x] **`api/auth.py` `except Exception` tightening** — every WS send /
|
||||
close site is now `except _WS_SEND_BENIGN_EXC` (a narrow tuple of
|
||||
WebSocketDisconnect / RuntimeError / ConnectionError / OSError).
|
||||
The auth-receive path catches the same set plus a final
|
||||
`logger.exception` catch-all for observability on truly unexpected
|
||||
shapes.
|
||||
- [x] **`(window as any)` cleanup** — 59 static-property accesses
|
||||
migrated to typed `window.<name>` against `global-types.d.ts`. The
|
||||
remaining 7 sites use dynamic string indexing (`window[fnName]`)
|
||||
and intentionally keep the cast (documented in the typedef file).
|
||||
|
||||
---
|
||||
|
||||
## Architecture refactors (multi-day — own session)
|
||||
|
||||
- [ ] **Split `core/processing/value_stream.py`** (1856 LOC, 14 stream classes)
|
||||
into a `value_streams/` package. Each value-stream type gets its own
|
||||
file ≤300 LOC; `manager.py` holds `ValueStreamManager`.
|
||||
- [ ] **Split `storage/color_strip_source.py`** (1841 LOC, 18 source kinds)
|
||||
into a `color_strip_sources/` package mirroring `value_streams/`.
|
||||
- [ ] **Frontend file splits** — `graph-editor.ts` (2707), `streams.ts`
|
||||
(2335), `value-sources.ts` (1889), `types.ts` (1062). Highest-churn
|
||||
modules; mixed UI / state / network responsibilities.
|
||||
- [ ] **Layering reversal**: introduce a neutral `domain/` package and move
|
||||
shared DTOs (`FilterInstance`, `CalibrationConfig`, etc.) into it so
|
||||
`storage/` no longer imports `core/`. Eliminates 7+ layering
|
||||
violations and the lazy-import hacks used to break the resulting
|
||||
circulars.
|
||||
- [ ] **`main.py` boot refactor** — extract import-time side effects into
|
||||
`bootstrap.py` + `create_app()` factory. `lifespan()` becomes the
|
||||
single place that wires stores and managers.
|
||||
- [ ] **DI consolidation** — replace `api/dependencies.py` getter sprawl
|
||||
(30+ `get_*()` functions reading a process-global `_deps` dict) with
|
||||
a single typed `get_container()` dependency. Makes test-overrides
|
||||
trivial; ban direct getter calls in handler bodies.
|
||||
- [ ] **Exception hierarchy** — define `ledgrab/errors.py` (`LedGrabError`,
|
||||
`NotFoundError`, `ValidationError`, `RemoteUnavailableError`,
|
||||
`SSRFBlockedError`). Move HTTP translation into a FastAPI exception
|
||||
handler. Stop raising `HTTPException` from `utils/safe_source.py`.
|
||||
- [ ] **Lazy-import audit** — 289 in-function `from ledgrab.*` imports.
|
||||
Specifically `core/processing/daylight_settings.py` imports
|
||||
`api.dependencies` (core → api inversion). Pass the database in via
|
||||
the constructor instead of service-locator lookup.
|
||||
|
||||
## Performance (profile before applying)
|
||||
|
||||
- [ ] **`composite_stream.py` blend modes** — pre-allocate scratch buffers
|
||||
in `_blend_override / overlay / hard_light / soft_light / difference
|
||||
/ exclusion`. Each currently allocates per frame (`mul`, `scr`,
|
||||
`blended`, `np.where(...)`). At 100 LEDs × 30 fps × N layers this
|
||||
adds up.
|
||||
- [ ] **`mapped_stream` / `composite_stream` zone resize** — replace the
|
||||
per-channel `np.interp` calls with a cached `floor/ceil/frac` LUT
|
||||
(same trick as `wled_target_processor._fit_to_device`) or a single
|
||||
`cv2.resize` call on the (N,3) array. `np.interp` allocates a new
|
||||
`float64` array per channel per frame even on cache-hit.
|
||||
- [ ] **`processed_stream._processing_loop`** — add ping-pong output
|
||||
buffers and pass them as `out=` to filter `process_strip()` calls.
|
||||
Today every filter that returns a fresh allocation costs us a copy
|
||||
per frame. Also: the loop uses `time.sleep` instead of an
|
||||
event-driven wait on the input stream — input updates faster than
|
||||
30 fps see up to `frame_time` of latency.
|
||||
- [ ] **`mqtt_client.py` `send_pixels`** — add a binary publish path (or
|
||||
at minimum cache the outer dict skeleton). Today every frame
|
||||
`pixels.tolist()` + `json.dumps` for ~300 LEDs × 30 fps × N devices.
|
||||
- [ ] **Frontend `static/js/features/color-strips/test.ts`** — cache
|
||||
`ImageData` per canvas (`canvas._imageData`); only re-create on
|
||||
dimension change; use a `Uint32Array` view to copy pixels in one
|
||||
loop instead of the per-pixel JS loop. Border-overlay rebuild on
|
||||
every frame should also be debounced to dimension changes only.
|
||||
- [ ] **`ws_stream.py` composite branch** — pre-allocate a `bytearray`
|
||||
sized to the largest frame and write into slices instead of
|
||||
`b"".join(tobytes()) per layer` every iteration. Same anti-pattern
|
||||
in `wled_target_processor._broadcast_led_preview`.
|
||||
- [ ] **Preview broadcast slow-client guard** — `asyncio.gather` over
|
||||
preview clients waits for the slowest. Move to `asyncio.wait` with a
|
||||
timeout and drop slow clients, or fire-and-forget with a
|
||||
`ws.application_state` filter.
|
||||
|
||||
## Security (deferred — non-trivial or design-sensitive)
|
||||
|
||||
- [ ] **Content-Security-Policy header** — would need careful tuning
|
||||
because the UI uses inline event handlers / Jinja templates.
|
||||
Mis-set CSP would break the app silently. Defer until templates can
|
||||
move to event-delegated handlers, then add a strict policy.
|
||||
- [x] **`api/auth.py` exception specificity** — done in the 2026-05-23
|
||||
pass; see top of file.
|
||||
- [ ] **Hue bridge cert pinning** — `httpx.AsyncClient(verify=False)` for
|
||||
Hue bridge (self-signed cert by design). Should record the
|
||||
certificate fingerprint at pairing time and pin it on subsequent
|
||||
requests; otherwise an on-path attacker can MITM the bridge.
|
||||
|
||||
## Mechanical / code-quality (low risk, high line-count)
|
||||
|
||||
- [ ] **i18n parity** — confirmed **328** keys missing in `ru.json` and
|
||||
**325** missing in `zh.json` against the canonical English file.
|
||||
Translation work — needs a native speaker, not a machine-translation
|
||||
pass. Run `py scripts/diff_locale_keys.py` (or copy the diff block
|
||||
out of the 2026-05-23 pass log) to get the exact key list.
|
||||
- [x] **`Optional[T]` → `T | None`** — done; `UP007` now enforced via
|
||||
`pyproject.toml` so the rule prevents regressions.
|
||||
- [ ] **Hot-path `logger.error(f"...")` → `logger.error("... %s", e)`**
|
||||
lazy-eval — 658 sites flagged by `ruff --select G004`. Deferred
|
||||
because it is genuinely cosmetic at ERROR level (always emitted)
|
||||
and the cumulative cost is negligible. Worth doing if/when ruff
|
||||
gains a safe autofix, or as a Codemod in a dedicated session.
|
||||
- [x] **Remaining `(window as any)` sites** — 59 migrated to typed
|
||||
`window.<name>` access; the 7 surviving sites use dynamic string
|
||||
indexing and are documented as the legitimate exception.
|
||||
- [x] **Magic numbers → named constants** — done; see `processed_stream`
|
||||
and `wled_target_processor` constants at the top of each module.
|
||||
- [ ] **Standardise `from __future__ import annotations`** — partially
|
||||
mooted by the UP007 cleanup. Files that previously relied on
|
||||
`Optional`/`Union` no longer need the future import; the few that
|
||||
already use `__future__` keep it for forward-reference convenience.
|
||||
A blanket policy would still help — leave as a stylistic followup.
|
||||
|
||||
## Test gaps
|
||||
|
||||
- [x] **Route-level integration test** for the WLED scheme inference —
|
||||
done; covers create + update in `tests/api/routes/test_devices_routes.py::TestWLEDSchemeInference`.
|
||||
- [x] **IPv6 public address regression** — done; pinned in
|
||||
`tests/test_url_scheme.py` for both bracketless and bracketed forms.
|
||||
|
||||
## Pre-existing issues surfaced during the audit (not in our diff)
|
||||
|
||||
These were flagged by the auditors but predate the review session — kept
|
||||
here as a future-work backlog:
|
||||
|
||||
- [x] **`icon-select.ts:_buildGrid` `item.icon` is interpolated raw** —
|
||||
audited; all callers pass project-owned literals or table-lookup
|
||||
results. Added a runtime sanitiser as defence-in-depth.
|
||||
- [x] **`devices.py` `manager.update_device_info(device_url=update_data.url)`**
|
||||
None-on-PATCH path — fixed; now falls back to `existing.url`.
|
||||
- [ ] **`asyncio.gather` over uncapped client lists** in preview broadcasts
|
||||
— slow clients block the loop. Already noted under Performance
|
||||
above; pre-existing.
|
||||
@@ -1,5 +1,703 @@
|
||||
# LedGrab TODO
|
||||
|
||||
## HTTP polling automation trigger
|
||||
|
||||
Goal: a new automation trigger that periodically polls an HTTP endpoint
|
||||
and activates a scene when the response matches a condition. Split into
|
||||
three single-responsibility entities so the endpoint can be reused
|
||||
beyond automations (e.g. as a value-source driving brightness/color):
|
||||
|
||||
- `HTTPEndpoint` (storage/http_endpoint.py) — connection definition:
|
||||
URL + auth + headers + timeout. NO polling cadence; NO extraction.
|
||||
- `HTTPValueSource` (storage/value_source.py, source_type='http') —
|
||||
references an endpoint + owns json_path + interval + min/max + EMA
|
||||
smoothing. Backed by `HTTPValueStream` (core/processing/value_stream.py)
|
||||
which lives under the existing `ValueStreamManager` (ref-counted,
|
||||
one poll task per unique value source).
|
||||
- `HTTPPollRule` (storage/automation.py) — thin: `{value_source_id,
|
||||
operator, value}`. Reads `stream.get_raw_value()` from the value
|
||||
source and compares with `_apply_operator`.
|
||||
|
||||
Pivoted from a 2-entity shape mid-build (was: HTTPSource+rule with
|
||||
interval+json_path mushed). The 3-entity shape mirrors HA's pattern
|
||||
(HomeAssistantSource → HAEntityValueSource → rule).
|
||||
|
||||
### Phase 1 — endpoint + value source + thin rule (backend) ✅
|
||||
|
||||
- [x] `storage/http_endpoint.py` — `HTTPEndpoint` dataclass with
|
||||
secret_box auth_token encryption + `__post_init__` plaintext
|
||||
invariant. NO `default_interval_s` (moved to value source).
|
||||
- [x] `storage/http_endpoint_store.py` — `HTTPEndpointStore` with
|
||||
`_migrate_plaintext_tokens()`. ID prefix `htep_`.
|
||||
- [x] `storage/database.py` — `"http_endpoints"` in `_ENTITY_TABLES`
|
||||
(replaces the old `"http_sources"`).
|
||||
- [x] `storage/value_source.py` — added `HTTPValueSource` alongside
|
||||
`HAEntityValueSource` (endpoint_id, json_path, interval_s,
|
||||
min/max, smoothing). Registered in `_VALUE_SOURCE_MAP`.
|
||||
- [x] `storage/value_source_store.py` — CRUD branch for `source_type =
|
||||
"http"` + new kwargs on create/update.
|
||||
- [x] `core/processing/value_stream.py` — `HTTPValueStream` with poll
|
||||
task + `get_value()` (normalized 0-1) + `get_raw_value()` (raw
|
||||
extracted value). Dispatched in `ValueStreamManager._create_stream`.
|
||||
Manager now takes `http_endpoint_store` so the stream can resolve
|
||||
endpoints at fetch time.
|
||||
|
||||
### Phase 2 — rule + engine wiring ✅
|
||||
|
||||
- [x] `storage/automation.py` — `HTTPPollRule` is now thin: just
|
||||
`{value_source_id, operator, value}` (no http_source_id, no
|
||||
json_path on the rule). Legacy keys silently dropped on load.
|
||||
- [x] `core/automations/automation_engine.py` — drops the standalone
|
||||
http_poll_manager; takes `value_stream_manager`. Engine
|
||||
`_sync_value_stream_refs` acquires/releases value streams for
|
||||
every enabled HTTPPollRule, mirroring the HA/MQTT sync pattern.
|
||||
`_evaluate_http_poll` reads `stream.get_raw_value()` and applies
|
||||
the operator. `_apply_operator` kept at module top.
|
||||
- [x] `api/schemas/automations.py` — RuleSchema fields are now
|
||||
`value_source_id + operator + value` (dropped http_source_id +
|
||||
json_path).
|
||||
- [x] `api/routes/automations.py` — `http_poll` factory updated.
|
||||
|
||||
### Phase 3 — CRUD endpoints + wiring ✅
|
||||
|
||||
- [x] `api/schemas/http_endpoints.py` — Create/Update/Response/List/Test
|
||||
(no interval field; that's on the value source).
|
||||
- [x] `api/routes/http_endpoints.py` — full CRUD + `/test` +
|
||||
plaintext-http-token warning.
|
||||
- [x] `api/schemas/value_sources.py` — `HTTPValueSource{Create,Update,Response}`
|
||||
added to the discriminated unions.
|
||||
- [x] `api/routes/value_sources.py` — `_RESPONSE_MAP` entry for
|
||||
`HTTPValueSource`.
|
||||
- [x] `api/__init__.py` — `http_endpoints_router` registered.
|
||||
- [x] `api/dependencies.py` — `get_http_endpoint_store` (dropped the
|
||||
http_poll_manager getter).
|
||||
- [x] `main.py` — instantiate `HTTPEndpointStore`, pass it through
|
||||
`ProcessorDependencies`, wire `value_stream_manager` +
|
||||
`value_source_store` into `AutomationEngine`.
|
||||
- [x] `core/processing/processor_manager.py` — `ProcessorDependencies`
|
||||
gains `http_endpoint_store`; threaded into `ValueStreamManager`.
|
||||
|
||||
### Phase 4 — tests ✅
|
||||
|
||||
- [x] `tests/storage/test_http_endpoint_store.py` — 14 tests (CRUD +
|
||||
auth_token encryption + headers + case-insensitive Authorization).
|
||||
- [x] `tests/core/test_automation_engine.py` — `TestApplyOperator` +
|
||||
`TestHTTPPollRuleEvaluation` (new shape: mock ValueStreamManager
|
||||
with `_streams` dict) + `TestSyncValueStreamRefs` (acquire /
|
||||
release / disabled-ignored) + `TestHTTPValueStreamExtraction`
|
||||
(`_extract_simple_path` now lives in value_stream.py).
|
||||
- [x] `tests/api/routes/test_http_endpoints_routes.py` — CRUD shape, no
|
||||
auth_token leak in responses, schema-layer method allowlist,
|
||||
CRLF / invalid header rejection, `/test` endpoint, LAN policy.
|
||||
- [x] Removed: `tests/core/test_http_poll_manager.py` (manager deleted —
|
||||
polling now lives inside `HTTPValueStream`).
|
||||
- [x] Full suite: 1426 passed, ruff clean.
|
||||
|
||||
### Phase 5 — frontend ✅
|
||||
|
||||
- [x] `static/js/features/http-endpoints.ts` (new, ~540 LOC) — endpoint
|
||||
CRUD, modal subclass with dirty-check, headers row editor, test
|
||||
result rendering, card builder, event delegation. Mirrors
|
||||
`home-assistant-sources.ts`.
|
||||
- [x] `templates/modals/http-endpoint-editor.html` (new) — sectioned
|
||||
rack-panel modal (Identity / Request / Headers / Notes) with
|
||||
IconSelect method picker, password-toggle on auth token, inline
|
||||
Test button + result block.
|
||||
- [x] `static/js/features/value-sources.ts` — added `http` branch with
|
||||
EntitySelect over `httpEndpointsCache`, edit-data/defaults,
|
||||
`onValueSourceTypeChange` section toggle, save-payload assembly
|
||||
+ required-field validation.
|
||||
- [x] `templates/modals/value-source-editor.html` — new
|
||||
`#value-source-http-section` with endpoint picker + json_path +
|
||||
interval + min/max + smoothing.
|
||||
- [x] `static/js/features/automations.ts` — `http_poll` rule type with
|
||||
operator IconSelect + value-source EntitySelect; hides Value
|
||||
field when operator is `exists`.
|
||||
- [x] `static/js/features/integrations.ts` — `csHTTPEndpoints` section,
|
||||
tree/tab entry, render + reconcile + delegation paths.
|
||||
- [x] `static/js/types.ts` — `HTTPEndpoint`, `HTTPMethod`,
|
||||
`HTTPEndpointListResponse`, `HTTPTestRequest/Response`,
|
||||
`HTTPValueSource`, `HTTPPollOperator`; extended `RuleType` +
|
||||
`AutomationRule`.
|
||||
- [x] `static/js/core/state.ts` — `httpEndpointsCache` (`/http/endpoints`).
|
||||
- [x] `static/js/core/icons.ts` — `http: P.globe` in
|
||||
`_valueSourceTypeIcons`.
|
||||
- [x] `templates/index.html` — includes
|
||||
`modals/http-endpoint-editor.html`.
|
||||
- [x] Locales: 77 new keys per file in `en.json` / `ru.json` /
|
||||
`zh.json` (parity confirmed).
|
||||
- [x] Verification: `npx tsc --noEmit` clean; `npm run build` clean
|
||||
(app.bundle.css 366.6kb, app.bundle.js 2.7mb).
|
||||
|
||||
### Follow-ups (out of scope for initial PR)
|
||||
|
||||
- [ ] **Global concurrency cap / minimum interval.** Each
|
||||
`HTTPValueStream` runs its own task at `interval_s` (min 1s); no
|
||||
project-wide cap. Reviewer flagged: pick a min (e.g. 5s) + max
|
||||
active runtimes (e.g. 32) + shared `httpx.AsyncClient` with
|
||||
`limits=httpx.Limits(max_connections=N)`.
|
||||
- [ ] **DNS-rebinding hardening.** `safe_request_bounded` validates
|
||||
the URL hostname's resolved IPs once; httpx independently
|
||||
re-resolves. The window is short but not zero. True fix: pin
|
||||
to the validated IP + set Host header (and SNI for HTTPS). This
|
||||
affects every outbound caller (`safe_fetch`, weather, image
|
||||
sources) — handle as a project-wide hardening, not local to
|
||||
this feature.
|
||||
- [ ] **`delete_http_endpoint` orphan refs.** When an admin deletes an
|
||||
endpoint referenced by N value sources, the value-stream task
|
||||
keeps polling until its source is also deleted. Same shape as
|
||||
the MQTT defect — fix both together (refuse-with-409 when in
|
||||
use, or cascade value-source deletion).
|
||||
- [ ] **Per-endpoint `connected` / last-poll status on the response**
|
||||
(frontend agent flagged). `HTTPEndpointResponse` has no live
|
||||
status, unlike HA/MQTT sources. Card LEDs default to "on".
|
||||
Could aggregate `last_status_code` / `last_error` from all
|
||||
`HTTPValueStream` instances referencing the endpoint and surface
|
||||
on `GET /http/endpoints/{id}`.
|
||||
- [x] **Per-endpoint live `/test` after save** — added `POST
|
||||
/http/endpoints/{id}/test` (runs stored config server-side so the
|
||||
auth token never round-trips) and wired a flask-icon test action
|
||||
on the endpoint card (toasts the result). Custom-headers section
|
||||
and inline test-result UI in the editor modal also restyled to
|
||||
match the `.group-child-row` and result-card vocabulary.
|
||||
- [ ] **Dedicated icon for HTTP value source / endpoint** (frontend
|
||||
agent flagged). Both use `P.globe` — visually fine in practice
|
||||
but adding a `cable`/`webhook` glyph in `icon-paths.ts` would
|
||||
improve differentiation.
|
||||
|
||||
## Multi-broker MQTT refactor
|
||||
|
||||
Goal: drop the global `MQTTService` / `MQTTConfig`. Every MQTT consumer
|
||||
references an `MQTTSource.id`; `MQTTManager` is the only entry point.
|
||||
`MQTTManager` + `MQTTRuntime` already exist — the job is to migrate every
|
||||
caller off the legacy path, then delete it.
|
||||
|
||||
### Phase 1 — `mqtt_source_id` on Z2M target
|
||||
|
||||
- [x] Field on `Z2MLightOutputTarget` storage dataclass (+ to/from_dict)
|
||||
- [x] Field on Z2M create/update/response schemas
|
||||
- [x] Validate referenced `MQTTSource` exists at create/update
|
||||
- [x] Thread through `output_target_store.create_z2m_light_target` + update
|
||||
- [x] Thread through `ProcessorManager.add_z2m_light_target`
|
||||
- [x] Thread through `Z2MLightTargetProcessor` constructor
|
||||
|
||||
### Phase 2 — Z2M processor uses `MQTTManager`
|
||||
|
||||
- [x] Replace `_mqtt_service` with `_mqtt_runtime` acquired from manager
|
||||
- [x] `start()` acquire / `stop()` release
|
||||
- [x] `_publish_payload` → `self._mqtt_runtime.publish(...)`
|
||||
- [x] `turn_off_lights` borrow-pattern via manager (mirror HA-light)
|
||||
- [x] Add `mqtt_manager` to `ProcessorDependencies` / `TargetContext`
|
||||
|
||||
### Phase 3 — Z2M editor UI
|
||||
|
||||
- [x] Add MQTT broker `EntitySelect` in Routing
|
||||
- [x] Reuse `mqttSourcesCache`
|
||||
- [x] Wire `mqtt_source_id` into edit-load + save payload + validation
|
||||
|
||||
### Phase 4 — DIY MQTT device (`MQTTLEDClient`)
|
||||
|
||||
- [x] `mqtt_source_id` field on `Device` storage
|
||||
- [x] Field on `device_config.MQTTConfig`
|
||||
- [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()`
|
||||
- [x] Provider threads `mqtt_manager` via `ProviderDeps`
|
||||
- [ ] Device editor: MQTT source picker shown for `device_type=mqtt` *(UI still
|
||||
pending — backend accepts the field, but the device-create form doesn't
|
||||
expose it yet)*
|
||||
|
||||
### Phase 5 — `AutomationEngine`
|
||||
|
||||
- [x] Drop `mqtt_service` ctor parameter
|
||||
- [x] Drop legacy fallback in `_evaluate_mqtt` (rule must reference a source)
|
||||
|
||||
### Phase 6 — `api/routes/system.py`
|
||||
|
||||
- [x] Replace integration status with `mqtt_manager.get_all_sources_status()`
|
||||
- [ ] Update frontend dashboard payload (MQTT widget now expects a list of
|
||||
sources instead of a single `enabled`/`connected` pair — surface in UI)
|
||||
|
||||
### Phase 7 — Startup migration
|
||||
|
||||
- [x] Seed a "Default Broker" `MQTTSource` if legacy YAML / env had a
|
||||
broker configured and the store is empty (`core.mqtt.legacy_migration`)
|
||||
- [x] Deprecation warning logged on migration; YAML/env no longer read after
|
||||
|
||||
### Phase 8 — Remove legacy
|
||||
|
||||
- [x] Delete `core/mqtt/mqtt_service.py`
|
||||
- [x] Delete `set_mqtt_service` / `get_mqtt_service` (mqtt_client.py)
|
||||
- [x] Remove `MQTTService` from `main.py`
|
||||
- [x] Remove `MQTTConfig` + `resolve_mqtt_password` from `config.py`
|
||||
- [x] Remove `mqtt: MQTTConfig` from `Config` (with `extra="ignore"` so legacy
|
||||
YAML still loads)
|
||||
|
||||
### Phase 9 — Verification
|
||||
|
||||
- [x] `pytest tests/ --no-cov -q` clean (973 passing; removed obsolete
|
||||
`test_default_mqtt_disabled`)
|
||||
- [x] `ruff check src/` clean
|
||||
- [x] `tsc --noEmit` + `npm run build`
|
||||
- [ ] Smoke test: Z2M target on a configured MQTT Source publishes to broker
|
||||
(manual)
|
||||
|
||||
## Refactor: typed output-target factories + auto-registry
|
||||
|
||||
Replaced `target_type` string elif chains in `OutputTargetStore` and
|
||||
`OutputTarget.from_dict` with: (1) `__init_subclass__` registry for
|
||||
deserialization, (2) per-type typed `create_*_target` /
|
||||
`update_*_target` methods called directly from the route layer's
|
||||
`match data:` dispatch. API contract unchanged, no DB migration.
|
||||
|
||||
### Phase 1 — Registry on `OutputTarget`
|
||||
|
||||
- [x] Added `_registry` + `_type_key` ClassVars + `__init_subclass__(*, type_key)`
|
||||
- [x] Rewrote `OutputTarget.from_dict` to dispatch via registry
|
||||
- [x] Declared `type_key="led"` / `"ha_light"` / `"z2m_light"` on the three subclasses
|
||||
|
||||
### Phase 2 — Typed `create_*_target` methods
|
||||
|
||||
- [x] Extracted `_resolve_brightness`, `_resolve_transition`, `_check_unique_name`,
|
||||
`_new_id_and_now`, `_finalize` helpers on the store
|
||||
- [x] Added `create_wled_target` / `create_ha_light_target` / `create_z2m_light_target`
|
||||
with per-type defaults (transition 0.5/0.3, update_rate 2.0/5.0) baked into
|
||||
their signatures
|
||||
|
||||
### Phase 3 — Typed `update_*_target` methods
|
||||
|
||||
- [x] Added `update_wled_target` / `update_ha_light_target` / `update_z2m_light_target`
|
||||
with `_begin_update` / `_commit_update` helpers
|
||||
- [x] Each typed update method validates the target's class before mutating
|
||||
|
||||
### Phase 4 — Route migration
|
||||
|
||||
- [x] `create_target` route uses `match data:` to call typed store methods —
|
||||
no more `getattr(data, "x", default)` pyramid
|
||||
- [x] `update_target` route uses `match data:` and computes `settings_changed` /
|
||||
`css_changed` / `brightness_changed` per-arm from typed fields
|
||||
- [x] Helpers `_build_ha_mappings`, `_build_z2m_mappings`,
|
||||
`_validate_device_exists`, `_resolve_effective_color_vs_id` extracted
|
||||
|
||||
### Phase 5 — Decision: keep both shims
|
||||
|
||||
After grepping for callers, `src/ledgrab/core/scenes/scene_activator.py:90`
|
||||
calls `target_store.update_target(target_id, **changed)` with a dynamically
|
||||
built dict — it legitimately doesn't know the target's type at the call site.
|
||||
The shims are now ~30-line dispatchers that route to typed methods (no more
|
||||
inline construction elif chains), so the original anti-pattern is gone while
|
||||
the generic API remains available for "don't-know-the-type" callers like the
|
||||
scene activator. Tests continue to use the shorthand `create_target("A", "led")`
|
||||
form without churn.
|
||||
|
||||
### Phase 6 — Verify
|
||||
|
||||
- [x] `ruff check` clean on all modified files
|
||||
- [x] `py -3.13 -m pytest tests/ --no-cov -q` — 974 passed (was 974 before)
|
||||
- [ ] Manual smoke test in UI: create/edit/delete each of the three target types
|
||||
|
||||
## Custom card icons — extend to all card types
|
||||
|
||||
Migrate the existing icon-plate work (devices, LED targets, HA-light targets)
|
||||
to all remaining card types. ~17 entity types. Branch: `feat/icons-everywhere`.
|
||||
|
||||
### Foundation
|
||||
|
||||
- [x] Refactor `icon-picker.ts` — replace hardcoded 2-entry `_adapters`
|
||||
record with a `Map<EntityType, EntityTypeAdapter>` and expose
|
||||
`registerIconEntityType()` for feature modules to register their
|
||||
own. Added `makeSimpleIconAdapter()` helper that reduces a
|
||||
registration to ~6 lines.
|
||||
- [x] Generalised `bodyExtras` for discriminated routes (output-targets
|
||||
`target_type` etc.) — now keyed off id, adapter does its own
|
||||
lookup.
|
||||
- [x] `_onDocumentClick` accepts any registered type instead of
|
||||
hardcoded device/target check.
|
||||
- [x] Locale entity-type labels added to en/ru/zh for 18 new types
|
||||
(picture_source, audio_source, weather_source, value_source,
|
||||
mqtt_source, ha_source, automation, scene_preset, sync_clock,
|
||||
game_integration, audio_processing_template, pattern_template,
|
||||
capture_template, pp_template, cspt, audio_template, gradient,
|
||||
color_strip_source, asset).
|
||||
|
||||
### Backend (storage + schemas + routes per entity)
|
||||
|
||||
Recipe: add `icon: str = ""` + `icon_color: str = ""` to dataclass,
|
||||
emit-when-truthy in `to_dict`, default `""` in `from_dict`; add 3
|
||||
`Optional[str]` Field defs to Create/Response/Update schemas; thread
|
||||
`getattr(entity, "icon", "") or ""` into the response builder.
|
||||
SQLite JSON-blob storage means **no migration required**.
|
||||
|
||||
- [x] Integrations (6): weather_sources, value_sources, mqtt_source,
|
||||
home_assistant_source, sync_clocks, game_integration
|
||||
- [x] Streams (10): picture_source, audio_source, audio_template,
|
||||
audio_processing_template, pattern_template, postprocessing_template,
|
||||
color_strip_processing_template, color_strip_source, gradient,
|
||||
capture_template (`storage/template.py` — was missed by initial pass)
|
||||
- [x] Other (3): automation, scene_preset, asset
|
||||
|
||||
### Frontend (per feature module)
|
||||
|
||||
For each card render call:
|
||||
|
||||
- Use the new `core/card-icon.ts` helper:
|
||||
`...makeCardIconFields('<type>', entity.id, entity)` spread into the
|
||||
mod-card head — computes `iconHtml`/`iconColor`/`iconAttrs` in one go.
|
||||
- Register the entity type in the feature module via
|
||||
`registerIconEntityType('<type>', makeSimpleIconAdapter({ … }))`.
|
||||
|
||||
Modules wired:
|
||||
|
||||
- [x] streams.ts (7 cards: picture, capture, pp, cspt, audio source,
|
||||
audio template, gradient — built-in gradients skip the plate)
|
||||
- [x] automations.ts
|
||||
- [x] scene-presets.ts
|
||||
- [x] sync-clocks.ts
|
||||
- [x] weather-sources.ts
|
||||
- [x] value-sources.ts (bodyExtras propagates `source_type`)
|
||||
- [x] mqtt-sources.ts
|
||||
- [x] home-assistant-sources.ts
|
||||
- [x] game-integration.ts
|
||||
- [x] audio-processing-templates.ts
|
||||
- [x] assets.ts
|
||||
- [x] color-strips/cards.ts (bodyExtras propagates `source_type`)
|
||||
- [WONTDO] pattern-templates.ts — uses legacy `wrapCard({content, actions})`
|
||||
string API, not the mod-card system. Migration would be a separate
|
||||
effort and the cards are tiny (name + rect count) so the value is low.
|
||||
|
||||
### Discriminated routes
|
||||
|
||||
Adapters provide `bodyExtras` to inject the discriminator field on PUT
|
||||
so the Pydantic discriminated-union route validators don't reject the
|
||||
icon-only update:
|
||||
|
||||
- output-targets → `target_type` (already wired before)
|
||||
- color-strip-sources → `source_type`
|
||||
- audio-sources → `source_type`
|
||||
- value-sources → `source_type`
|
||||
- picture-sources → `stream_type`
|
||||
|
||||
### Verification
|
||||
|
||||
- [x] `cd server && ruff check src/ tests/` clean
|
||||
- [x] `cd server && npx tsc --noEmit` clean
|
||||
- [x] `cd server && npm run build` produces 2.6 MB bundle
|
||||
- [x] `cd server && py -3.13 -m pytest tests/ --no-cov -q` — 949 passed
|
||||
- [ ] Manual: open picker on each card type, confirm save persists,
|
||||
confirm channel-color preview matches the live card
|
||||
|
||||
## Device Event Notifications
|
||||
|
||||
Notify the user when LED devices come online/go offline (configured targets), and when new
|
||||
WLED/serial devices are discovered or disappear from the LAN/USB. Each event class has a
|
||||
configurable channel: `none` | `snack` | `os` | `both`. OS channel uses Web Notifications
|
||||
(works in any browser tab and in the PWA shell — no platform-specific Python).
|
||||
|
||||
Branch: `feat/device-event-notifications`. Default ON.
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] `core/devices/discovery_watcher.py` — long-running mDNS browser
|
||||
(`AsyncServiceBrowser` kept alive for the process lifetime) + 10 s serial-port
|
||||
poller. Fires `device_discovered`/`device_lost` via `processor_manager.fire_event`,
|
||||
suppresses events for URLs already in `device_store`. Seeded ports do NOT generate
|
||||
startup-time toasts.
|
||||
- [x] Wired into `lifespan` (`main.py`). Gated by `notification_preferences.
|
||||
background_discovery_enabled`. Default True. Stops before health monitor stop.
|
||||
- [x] `api/schemas/preferences.py` — `NotificationPreferences` Pydantic v2 model with
|
||||
the 4-event channel matrix, `background_discovery_enabled`, `startup_grace_sec`
|
||||
(0..300), `flap_debounce_sec` (0..60).
|
||||
- [x] `api/routes/preferences.py` — `GET/PUT /api/v1/preferences/notifications`,
|
||||
persisted under `db.set_setting("notification_preferences", …)`. Corrupt stored
|
||||
values fall back to defaults instead of 500.
|
||||
- [x] Reuses existing `device_health_changed` event from `device_health.py` (already
|
||||
fires online/offline transitions on the same event bus).
|
||||
- [x] Tests: 7 in `tests/test_preferences_notifications_api.py`, 6 in
|
||||
`tests/test_discovery_watcher.py`. Full pytest suite still 899 passing.
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] `js/features/notifications-watcher.ts` — listens to the three `server:*` DOM
|
||||
events. Applies user prefs. Pipeline: startup grace → flap debounce → bulk
|
||||
coalesce (≥3 events / 800 ms collapse to one summary).
|
||||
- [x] Web Notification permission requested from the Settings → Notifications panel
|
||||
via a user-gesture button. State chip reflects granted/denied/default.
|
||||
- [x] Settings panel — new "Notifications" subtab between Backup and Appearance.
|
||||
4 IconSelects (`none`/`snack`/`os`/`both`) + background-discovery toggle +
|
||||
permission row + Test-notification button.
|
||||
- [x] i18n: `settings.notifications.*` and `notifications.*` keys in en/ru/zh.
|
||||
|
||||
### Verification (notifications)
|
||||
|
||||
- [x] `npx tsc --noEmit` clean, `npm run build` produces 2.5 MB bundle.
|
||||
- [x] `ruff check src/ tests/` clean. 899/899 pytest pass.
|
||||
- [x] App import smoke-test (`from ledgrab.main import app`) loads 233 routes
|
||||
without errors.
|
||||
- [ ] Real-hardware test pending — verify on user's network:
|
||||
(1) plug a fresh WLED in → snack toast appears, (2) configure it → next
|
||||
offline transition fires both snack + OS toast, (3) Background-discovery
|
||||
toggle off → no more discovered/lost events.
|
||||
|
||||
### Out of scope for v1
|
||||
|
||||
- Per-device-type granularity (we ship one matrix per event-type, no device-type split)
|
||||
- Per-device mute list (deferred — user can globally toggle off if noisy)
|
||||
- Native OS toast via Windows winrt API (Web Notifications cover the use case;
|
||||
also avoids the `os_notification_listener` feedback loop)
|
||||
- Notification history panel — could land later as the reserved `alerts` dashboard cell
|
||||
|
||||
## Server shutdown action
|
||||
|
||||
Let user choose what happens to LED targets on server shutdown.
|
||||
|
||||
- [x] Backend storage: `shutdown_action` in `db.settings` (`"stop_targets"` default | `"nothing"`)
|
||||
- [x] Backend route: `GET/PUT /api/v1/system/shutdown-action` in `system_settings.py`
|
||||
- [x] Backend schema: `ShutdownActionResponse/Request` in `schemas/system.py`
|
||||
- [x] Backend wiring: lifespan shutdown in `main.py` reads action, passes `restore_devices` flag to `processor_manager.stop_all()`
|
||||
- [x] `processor_manager.stop_all(restore_devices: bool = True)` — when False, calls public `proc.cancel_task()` (defined on `TargetProcessor`) which awaits cancellation without restoring device state; skips `_restore_device_idle_state` loop. No reach into private `_task` attribute.
|
||||
- [x] Frontend: hidden `<select>` + IconSelect in `settings.html` General tab (icons via `ICON_SQUARE` / `ICON_CIRCLE` from `core/icons.ts`)
|
||||
- [x] Frontend: load/save handlers in `features/settings.ts`, wired into `openSettingsModal()`
|
||||
- [x] i18n: en / ru / zh keys for label, hint, item descriptions
|
||||
- [ ] Real-hardware test pending — verify that "nothing" actually leaves a WLED + a serial device on the last frame after `Ctrl+C`/SIGTERM.
|
||||
|
||||
## WebUI Redesign — "Lumenworks" Studio-Console Aesthetic
|
||||
|
||||
Full-app UI/UX refresh. Design direction committed to by user 2026-04-24.
|
||||
Mockup lives at [server/docs/ui-redesign-mockup.html](server/docs/ui-redesign-mockup.html).
|
||||
Phases are independent and CSS-only where possible — backend untouched.
|
||||
|
||||
### Phase 1 — Design tokens & font embed
|
||||
|
||||
- [x] Embed variable fonts (`server/src/ledgrab/static/fonts/`):
|
||||
Manrope (latin + latin-ext + cyrillic + cyrillic-ext),
|
||||
JetBrains Mono (same 4 subsets),
|
||||
Big Shoulders Display (latin + latin-ext). Total +201 KB gzipped,
|
||||
served via `unicode-range` so only latin paints on first load.
|
||||
- [x] `fonts.css` — declare `@font-face` entries for all new families with
|
||||
proper `unicode-range` subsetting; keep DM Sans + Orbitron registered
|
||||
for legacy-token callers during migration.
|
||||
- [x] `base.css` — add additive Lumenworks tokens:
|
||||
`--font-display/--font-brand/--font-body`, `--lux-r-*`, `--lux-hairline`,
|
||||
`--lux-rule`. Both `[data-theme="dark"]` and `[data-theme="light"]`
|
||||
define `--lux-bg-0…3`, `--lux-line/-bold`, `--lux-ink/-dim/-mute/-faint`,
|
||||
`--ch-signal/-cyan/-magenta/-amber/-coral/-violet`, `--lux-signal-glow`,
|
||||
`--lux-shadow-rack`. Existing tokens untouched — no visual regression.
|
||||
|
||||
### Phase 2 — Shell (header → transport bar + channel-strip sidebar)
|
||||
|
||||
- [x] `index.html` — `.tab-bar` moved out of `<header>` into a new
|
||||
`<aside class="sidebar">`; wrapped content in `.app-body` 2-col grid
|
||||
(sidebar | main). `.transport-center` section added between
|
||||
`.header-title` and `.header-toolbar` with a placeholder `.transport-status`
|
||||
chip ("Ready" → "Armed · N live" wired in Phase 3). All tab-button IDs,
|
||||
`data-tab` attributes, and `onclick="switchTab(…)"` handlers preserved.
|
||||
- [x] `layout.css` — `<header>` rebuilt as the transport bar: 3-column grid
|
||||
(brand | center | toolbar), 60 px fixed height, sticky, gradient bottom
|
||||
rule with channel-color wash. `.header-title::before/::after` render
|
||||
the glowing LED brand mark; `#server-status` repositioned as the LED
|
||||
core pip. `#server-version` restyled as a mono-type console badge.
|
||||
- [x] `sidebar.css` (new) — vertical channel-strip navigation. Active tab
|
||||
gets a glowing left stripe + radial tint. `.sidebar-foot` contains
|
||||
a `.cpu-meter` plate with two live bars (Load, FPS) ready to be
|
||||
JS-bound in Phase 3. Collapses to a 56 px icon rail at ≤1100 px;
|
||||
hides entirely at ≤600 px via `display: contents` so `.tab-bar`
|
||||
falls through to `mobile.css`'s fixed-bottom strip unchanged.
|
||||
- [x] `all.css` — new sidebar import after layout.
|
||||
- [x] `base.css` — body font-family switched to `var(--font-body)` which
|
||||
resolves to Manrope (with DM Sans + system fallbacks). Added
|
||||
`font-feature-settings` for stylistic set + alternate 1.
|
||||
- [x] Locale additions: `sidebar.workspaces`, `sidebar.load`, `sidebar.fps`,
|
||||
`transport.status.ready`, `transport.status.armed` in en/ru/zh.
|
||||
- [x] Tutorial + auth selectors (`header .header-title`, `#tab-btn-*`,
|
||||
`.tab-bar` querySelector, `a.header-link[href="/docs"]`, onclick
|
||||
markers on theme/settings/search) all survive the move.
|
||||
- [ ] JS: bind `.cpu-meter` + `.transport-status` chip to existing
|
||||
`performance` WebSocket / poller. Done as part of Phase 3.
|
||||
- [ ] Tablet-range visual polish pass once other phases render (some tabs
|
||||
currently have their own internal sticky headers that may overlap
|
||||
the transport bar on narrow viewports).
|
||||
|
||||
### Phase 3 — Dashboard hero + module redesign
|
||||
|
||||
- [x] `cards.css` — `.card` gets rack-module treatment: channel stripe on
|
||||
left edge (color-coded via `data-card-type` + `.ch-*` utility classes),
|
||||
`::after` corner bracket in top-right, mono-typed metric labels
|
||||
planned for Phase 4. Running cards glow the stripe brighter + emit a
|
||||
`signalFlow` keyframe strip along the bottom edge.
|
||||
- [x] Removed the `@property --border-angle` rotating conic-gradient border
|
||||
(retired the WebKit mask workaround + light-theme variant + fallback
|
||||
for `@supports not (mask-composite: exclude)`). Replaced with the
|
||||
signal-flow strip — one animated linear-gradient on a 2 px line, no
|
||||
GPU layer compositing per card.
|
||||
- [x] `dashboard.css` — `.dashboard-target` rows pick up the same channel
|
||||
stripe + signal-flow treatment. Section headers now use mono caps
|
||||
with a channel-green underline accent. Metric values use mono with
|
||||
tabular numerics; labels use silkscreened micro-caps.
|
||||
- [x] Skeleton-card rewritten: left hairline + corner bracket so it reads
|
||||
as "loading module" instead of a generic flashing block.
|
||||
`skeletonShimmer` gradient replaces the old opacity-pulse on
|
||||
`--text-color`.
|
||||
- [x] `_updateSidebarMeter` binds CPU% (Load) and app-CPU share (FPS)
|
||||
to the sidebar meter plate on every perf poll.
|
||||
- [x] `_updateTransportStatus` updates the transport chip ("Ready" →
|
||||
"Armed · N live") whenever the dashboard's running-target set is
|
||||
recomputed.
|
||||
- [ ] `.hero` 4-cell readout row (Active Patches / Throughput / CPU /
|
||||
Latency + inline sparklines) — CSS tokens + layout are ready; HTML
|
||||
render deferred until the dashboard JS is refactored to emit it
|
||||
(Phase 3b, non-blocking).
|
||||
|
||||
### Phase 4 — Other tabs adopt module language
|
||||
|
||||
- [x] `tree-nav.css` — trigger pill gets a channel stripe on its left edge
|
||||
(glows + widens when open). Trigger title uses mono-uppercase with
|
||||
wide letter-spacing. Dropdown panel has a gradient channel-accent
|
||||
rule across its top edge. Group headers use silkscreened micro-caps
|
||||
with a small square marker instead of the old bold-uppercase. Active
|
||||
leaf has a pulsing LED pip on the left and a channel tint behind it.
|
||||
Count badges switched to mono tabular-nums in 2-px-radius pills.
|
||||
- [x] `.subtab-section-header` — channel-green underline accent + mono
|
||||
micro-caps. Consistent with the dashboard-section pattern so the
|
||||
whole app shares one section-header language.
|
||||
- [x] `.stream-tab-btn` sub-tabs — mono uppercase with wide tracking,
|
||||
active tab shows channel-green underline + glowing count badge.
|
||||
- [x] `.perf-chart-card` — channel stripe on the left (replaces old
|
||||
`border-top` accent). Per-metric accents swapped to channel palette
|
||||
(`--ch-coral` for CPU, `--ch-violet` for RAM, `--ch-signal` for GPU,
|
||||
`--ch-amber` for temp). Corner bracket added. Metric values pick up
|
||||
`tabular-nums` + a soft glow.
|
||||
- [x] `cards.css` — channel-color mapping extended to attributes the JS
|
||||
already emits (`data-target-id` → green, `data-stream-id` → cyan,
|
||||
`data-audio-source-id` → magenta, `data-automation-id` /
|
||||
`data-scene-id` → violet). No JS changes required; cards pick up
|
||||
their correct stripe automatically on the Targets/Sources/Automations
|
||||
tabs.
|
||||
- [x] Graph editor — toolbar gets a gradient background + hairline +
|
||||
rack shadow + backdrop blur. Canvas and nodes untouched.
|
||||
- [x] `.template-card` — Lumenworks treatment (channel stripe on left,
|
||||
corner bracket top-right, hairline border, hover lift + stripe
|
||||
glow). Brings Inputs (streams / capture / pp / cspt / pattern
|
||||
templates) and Integrations (HA / MQTT / weather / value /
|
||||
sync-clock / game-integration cards) up to the same visual
|
||||
language as `.card` and `.dashboard-target`.
|
||||
- [x] `cards.css` — channel mapping extended to `.template-card`.
|
||||
Direct attr hooks for `data-stream-id`/`data-template-id`/`data-pp-template-id`
|
||||
(cyan), `data-cspt-id`/`data-pattern-template-id` (signal),
|
||||
`data-audio-template-id`/`data-apt-id` (magenta). Section-scoped
|
||||
hooks via `[data-card-section="…"]` for cards that share a
|
||||
generic `data-id` (HA / MQTT / weather / value → cyan;
|
||||
game-integrations → amber; sync-clocks → violet; HA-light-targets
|
||||
→ signal). No JS changes — uses the section markup `CardSection`
|
||||
already emits.
|
||||
- [x] Graph editor nodes — body fill `--lux-bg-1` with hairline stroke,
|
||||
hover bold-line, selected/running stroke `--ch-signal` with
|
||||
drop-shadow glow. Title font switched from DM Sans to
|
||||
`--font-display`; subtitle to mono uppercase wide-tracking.
|
||||
Port-drop-target glow recoloured to `--ch-signal`. Port labels
|
||||
adopt the mono caption treatment. Grid dots use `--lux-line`.
|
||||
Running gradient stops switched from `--primary-color`/`--success-color`
|
||||
to channel palette (signal → cyan → signal).
|
||||
|
||||
### Phase 5 — Modal restyle
|
||||
|
||||
- [x] `modal.css` — backdrop gains a radial dim + 6 px blur for stronger
|
||||
separation. `.modal-content` gets a gradient background + hairline +
|
||||
deep rack shadow. Channel-accent rule across the top edge driven by
|
||||
`--modal-ch` (per-modal override). Corner bracket bottom-right on
|
||||
desktop. `.modal-header` gains a vertical channel-color stripe to
|
||||
the left of the title; `.modal-footer` picks up a hairline divider.
|
||||
- [x] Per-modal channel mapping by modal ID:
|
||||
- Target editors → green
|
||||
- Input/Source editors → cyan
|
||||
- Audio editors → magenta
|
||||
- Automation / Scene / Game editors → violet
|
||||
- Settings / API key / Setup / Notifications → amber
|
||||
- Confirm dialog → coral
|
||||
- [x] `components.css` — inputs use hairline borders, tabular-nums mono
|
||||
for `input[type="number"]`, channel-green focus ring + glow. Buttons
|
||||
use mono-uppercase type, signal-glow on primary, coral-glow on
|
||||
danger. `<select>` audit deferred (project already enforces via
|
||||
CLAUDE.md rule + IconSelect/EntitySelect wrappers).
|
||||
|
||||
### Phase 6 — Mobile dedicated shell
|
||||
|
||||
- [x] `mobile.css` (existing file, not forked) — fixed-bottom `.tab-bar`
|
||||
promoted to full Lumenworks treatment: gradient background + hairline
|
||||
divider at top + channel-accent rule matching the transport-bar
|
||||
bottom. Active tab gets an LED pip above the icon and a channel-tint
|
||||
background. Tab labels + badges use mono uppercase to match the
|
||||
rest of the app. Phone (≤600 px): modal corner-bracket hidden
|
||||
(fullscreen modals), modal-header stripe slimmed to 18 px.
|
||||
- [x] Phase 2's layout.css already strips the transport-center on phones
|
||||
and collapses the sidebar via `display: contents`, so the mobile
|
||||
shell automatically routes the tab-bar to the bottom without a
|
||||
separate JS hook.
|
||||
- [WONTDO] Fork into `mobile-shell.css` — keeping changes in `mobile.css`
|
||||
since the cascade was already organized by viewport. A rename adds
|
||||
churn without improving maintainability.
|
||||
|
||||
### Phase 7 — Microcopy + retire legacy
|
||||
|
||||
- [x] Locale rename: `targets.title` + `dashboard.section.targets` →
|
||||
"Channels" (en) / "Каналы" (ru) / "通道" (zh);
|
||||
`streams.title` → "Inputs" / "Входы" / "输入".
|
||||
Automations kept as-is (Automations + Scenes is a meaningful
|
||||
distinction; "Patches" would conflate them). Internal tab keys
|
||||
(`dashboard` / `automations` / `targets` / `streams` / `integrations`
|
||||
/ `graph`) unchanged so no JS or localStorage migration needed.
|
||||
- [x] Ambient WebGL background — default is already `off`; kept the
|
||||
toggle button and localStorage preference so users who want the
|
||||
shader can turn it on. No entry-point change needed: `data-bg-anim`
|
||||
is initialized from localStorage with `off` fallback.
|
||||
- [DEFERRED] Delete DM Sans + legacy color tokens — would cascade through
|
||||
every file that reads `--primary-color` / `--text-color` etc. Safer
|
||||
as a separate cleanup PR after the new design has soaked.
|
||||
- [WONTDO] Delete `mobile.css` — Phase 6 kept the filename.
|
||||
|
||||
## Dashboard Customization
|
||||
|
||||
Per-account dashboard layout — slide-in Customize panel lets users
|
||||
toggle section / perf-cell visibility, reorder via drag, change density,
|
||||
pick presets, and import/export the layout as JSON. Server-synced via
|
||||
`db.get_setting('dashboard_layout')` so settings follow the user.
|
||||
|
||||
- [x] `js/features/dashboard-layout.ts` — schema (open registry of section
|
||||
/ perf-cell keys so v1.1 cards slot in with no migration), defaults,
|
||||
5 built-in presets (Studio/Operator/Showrunner/Diagnostics/TV),
|
||||
localStorage cache + server sync, legacy-key migration from
|
||||
`dashboard_collapsed`, `perfMetricsMode`, `perfChartColor_*`.
|
||||
- [x] `api/routes/preferences.py` — `GET/PUT/DELETE
|
||||
/api/v1/preferences/dashboard-layout`. Treats payload as opaque
|
||||
(frontend owns the schema); validates only that body is an object
|
||||
with a numeric `version`. 6 pytest tests in
|
||||
`tests/test_preferences_api.py` cover round-trip, default-empty,
|
||||
validation, delete, and unknown-field passthrough.
|
||||
- [x] `js/features/dashboard.ts` — sections rendered into a fragment map,
|
||||
then assembled in layout-driven order; perf section stays pinned
|
||||
top (chart-persistence reasons) but its visibility is layout-
|
||||
driven. Layout-change subscription invalidates the in-place-update
|
||||
optimization so density / order / visibility changes always
|
||||
rebuild section HTML.
|
||||
- [x] `js/features/perf-charts.ts` — `renderPerfSection()` iterates
|
||||
`getOrderedPerfCells()`; existing legacy `setPerfMode` writes
|
||||
through to the layout so the global toggle and the customize
|
||||
panel stay in sync.
|
||||
- [x] `js/features/dashboard-customize.ts` + `css/dashboard-customize.css`
|
||||
— slide-in panel, hand-rolled HTML5 drag-and-drop reorder, ↑/↓
|
||||
buttons for keyboard / TV remote, debounced (300 ms) autosave,
|
||||
live preview while open. Reset / export / import actions.
|
||||
- [x] i18n keys for `dashboard.customize.*` in en/ru/zh.
|
||||
- [ ] (v1.1) Audio meters section — peak / RMS / BPM bars per audio
|
||||
source. Schema key `audio-meters` already reserved.
|
||||
- [ ] (v1.1) Alerts section — quiet by default, loud on issues.
|
||||
Reserved key `alerts`.
|
||||
- [ ] (v1.1) Live LED preview strip per running device. Reserved
|
||||
key `led-preview`.
|
||||
- [ ] (v1.1) Source thumbnails grid (1 fps multiviewer). Reserved
|
||||
key `source-thumbs`.
|
||||
- [ ] (v1.2) Pinned section (user-curated mix of targets / scenes /
|
||||
devices). Reserved key `pinned`.
|
||||
- [ ] (v1.2) Patch/flow map — read-only mini graph of routing.
|
||||
Reserved key `flow`.
|
||||
|
||||
## BLE LED Controller Support (SP110E / Triones / Zengge / Govee)
|
||||
|
||||
Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming.
|
||||
@@ -120,9 +818,165 @@ Beyond the `/proc`-based AndroidMetricsProvider that's now in place:
|
||||
|
||||
## Refactor: Per-Provider Device Configs
|
||||
|
||||
Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses. Full plan: [docs/plans/device-typed-configs.md](docs/plans/device-typed-configs.md).
|
||||
Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses.
|
||||
|
||||
- [x] Phase 1 — `DeviceConfig` hierarchy + `Device.to_config()` (non-breaking, additive only)
|
||||
- [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR)
|
||||
- [x] Phase 4 — migrate `tests/test_group_device.py` to `GroupConfig`/`ProviderDeps`; remove legacy `GroupLEDClient` init path; 47-test config suite with 100% coverage on `device_config.py`
|
||||
- [ ] Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in `api/schemas/devices.py`; scope frontend POST/PATCH payloads by `device_type`
|
||||
|
||||
## Expand device support (Phase 1: open protocols)
|
||||
|
||||
Branch: `feat/expand-device-support`.
|
||||
|
||||
Goal: maximize the universe of LED controllers LedGrab can drive by adding aggregator + open-protocol providers in roughly-this order. Each driver follows the established `LEDDeviceProvider` + `*Config` + tests pattern.
|
||||
|
||||
### Phase 1.1 — Standalone DDP target ✅ shipped (commit `8f1140a`)
|
||||
|
||||
DDP packet layer (previously WLED-internal) promoted to a first-class device
|
||||
type. Pixelblaze, ESPixelStick, xLights/Falcon endpoints, and generic DDP
|
||||
receivers are now drivable directly without WLED in the path.
|
||||
|
||||
### Phase 1.2 — Yeelight LAN
|
||||
|
||||
Xiaomi/Yeelight bulbs, port 55443 TCP JSON. Direct protocol (no
|
||||
`python-yeelight` dependency — implementation is ~200 lines).
|
||||
|
||||
- [x] `YeelightConfig` dataclass with `yeelight_min_interval_ms` rate limit
|
||||
- [x] `YeelightClient` in `core/devices/yeelight_client.py` — TCP JSON-RPC,
|
||||
averaging single-pixel adapter, client-side rate gate
|
||||
- [x] SSDP-style discovery (Yeelight's variant on `239.255.255.250:1982`)
|
||||
- [x] `YeelightDeviceProvider` with validate/health/discover
|
||||
- [x] Storage + API schemas + route handler wiring
|
||||
- [x] 34 unit tests (URL parsing, RGB packing, averaging, rate limit, SSDP
|
||||
parsing, provider validate/discover, Device.to_config round-trip)
|
||||
- [ ] Frontend: Yeelight in device-type picker + edit form (spawned to a
|
||||
`frontend-design` subagent)
|
||||
- [ ] Locale strings (en/ru/zh)
|
||||
- [ ] Music mode (~60 Hz updates via reverse-TCP) — follow-up, current
|
||||
MVP caps at ~2 Hz via the client-side rate gate
|
||||
|
||||
### Phase 1.3 — WiZ Connected
|
||||
|
||||
Philips' UDP-local budget tier. Port 38899 JSON UDP.
|
||||
|
||||
- [x] `WiZConfig` + `WiZClient` + `WiZDeviceProvider`
|
||||
- [x] UDP broadcast discovery on 255.255.255.255:38899 with the standard
|
||||
`registration` envelope; replies parsed for IP+MAC.
|
||||
- [x] Sync `send_pixels_fast` for the hot loop (UDP is fire-and-forget,
|
||||
no async needed). 50 ms default min interval → ~20 Hz cap.
|
||||
- [x] Health check sends `getPilot` and waits for any reply.
|
||||
- [x] Storage + API schemas + route handler wiring
|
||||
- [x] 36 unit tests
|
||||
- [ ] Frontend: WiZ in device-type picker + edit form
|
||||
- [ ] Locale strings (en/ru/zh)
|
||||
|
||||
### Phase 2 — Unified discovery + pairing UX layer
|
||||
|
||||
After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen for replies, present a list". Factor that out into a generic discovery scaffold + a "first-run pairing" UX component before adding Tuya/Govee/etc., which each need a one-time pairing dance.
|
||||
|
||||
- [WONTDO] Generic `NetworkDiscoveryService` — the existing
|
||||
`/api/v1/devices/discover` route already runs all providers in parallel
|
||||
via `asyncio.gather(return_exceptions=True)`. Extracting it would not
|
||||
unlock anything; revisit only if discovery cadence/dedup becomes a
|
||||
real complaint.
|
||||
- [WONTDO] Unified scan UI — already exists; one "Scan network" button
|
||||
triggers the cross-provider fan-out.
|
||||
- [x] **Reusable pair-device scaffold** (the actually-needed piece).
|
||||
Backend: `LEDDeviceProvider.pair_device(url)` abstract method with
|
||||
`PairingNotReady` sentinel; `POST /api/v1/devices/pair` endpoint
|
||||
with status-code mapping (200/400/409/422/502); 8 route tests
|
||||
covering every outcome. Frontend: `templates/modals/pair-device.html`
|
||||
five-state modal (idle / pairing / not-ready / success / failed)
|
||||
with a 30-second SVG progress ring; reusable
|
||||
`static/js/features/pairing-flow.ts` exposing
|
||||
`runPairingFlow({deviceType, url}) → Promise<{fields}>` with
|
||||
`PairingCancelled` sentinel; locale strings in en/ru/zh. No driver
|
||||
uses it yet — Nanoleaf will be the first concrete consumer.
|
||||
|
||||
### Phase 3 — Big aggregator unlocks
|
||||
|
||||
- [ ] ESPHome native API (`aioesphomeapi`)
|
||||
- [ ] Tuya Local (`tinytuya`) — biggest single market unlock; needs the pairing UX from Phase 2
|
||||
- [ ] Matter over IP (forward-looking)
|
||||
- [ ] Hyperion JSON downstream
|
||||
|
||||
### Phase 4 — Major consumer brands
|
||||
|
||||
- [x] **LIFX LAN** — UDP binary protocol on port 56700; RGB→HSBK 16-bit
|
||||
conversion; broadcast discovery via GetService/StateService probe;
|
||||
47 unit tests. Single-pixel adapter shape, identical to WiZ
|
||||
structurally. Frontend wired via subagent.
|
||||
- [x] **Govee LAN API** — UDP JSON on port 4003 (control) + 4002
|
||||
(responses) + 4001 (multicast discovery on 239.255.255.250).
|
||||
Single-pixel `colorwc` command with `colorTemInKelvin=0` for RGB
|
||||
mode. **Per-device "LAN Control" toggle required in Govee Home
|
||||
app.** 40 unit tests. Frontend wired via subagent.
|
||||
- [x] **Nanoleaf OpenAPI** — Light Panels / Canvas / Shapes / Lines /
|
||||
Elements via HTTP REST on port 16021. **First concrete user of
|
||||
the pairing-UX scaffold from Phase 2.** mDNS discovery via
|
||||
`_nanoleafapi._tcp`. Single-pixel adapter (averaged strip → HSB
|
||||
`PUT /state`). Auth token encrypted at rest via `_enc`/`_dec`.
|
||||
42 unit tests covering URL parsing, RGB→HSB conversion, pairing
|
||||
handshake (200/403/500/missing-token/transport-error), state
|
||||
mutations, brightness clamping, Device.to_config round-trip
|
||||
including encrypted-token roundtrip.
|
||||
- [ ] Twinkly — multi-pixel + login flow; deferred
|
||||
- [WONTDO] Mi-Light / MiBoxer UDP gateway — the recommended path for
|
||||
modern Mi-Light deployments is `esp8266_milight_hub` firmware → MQTT,
|
||||
which LedGrab already supports through the existing MQTT device target
|
||||
(commit `530316c`). Native V6 driver would be ~400 lines + finicky
|
||||
session protocol + custom 1-byte hue table; the marginal benefit over
|
||||
the MQTT path is small. Revisit if a user complaint surfaces.
|
||||
|
||||
### Phase 5 — Open pixel protocols (cheap completionism)
|
||||
|
||||
- [x] **OPC (Open Pixel Control)** — TCP, port 7890, 4-byte header
|
||||
`[channel][cmd][len_hi][len_lo]` + RGB body. Channel 0 broadcasts.
|
||||
Single-pixel-strip protocol, no discovery, no pairing. 36 unit
|
||||
tests. Fadecandy + xLights + hobbyist receivers reachable.
|
||||
- [ ] TPM2.net
|
||||
|
||||
### Phase 6 — PC gaming RGB completion
|
||||
|
||||
- [ ] Corsair iCUE SDK
|
||||
- [ ] Logitech LIGHTSYNC
|
||||
- [ ] ASUS Aura SDK
|
||||
|
||||
### Phase 7 — Proprietary USB HID ambient kits
|
||||
|
||||
- [ ] Generic HID-ambient framework + VID/PID registry
|
||||
- [ ] First reverse-engineered target (probably Govee Immersion / DreamView)
|
||||
|
||||
### Cleanup + verification
|
||||
|
||||
- [x] **`_average_color` extraction** (commit `cc87fba`). Six identical
|
||||
copies (Yeelight / WiZ / LIFX / Govee / Nanoleaf / BLE) collapsed
|
||||
into `core/devices/pixel_reduce.average_color`. Net -76 lines.
|
||||
Hue is out by design — its Entertainment API addresses up to seven
|
||||
lights individually.
|
||||
- [x] **Pre-merge verification pass.** 1358 pytest tests pass; ruff
|
||||
clean across all device modules and tests; black clean against
|
||||
the pre-commit-pinned 24.10.0; `npx tsc --noEmit` clean; bundle
|
||||
compiles.
|
||||
- [x] **Pre-merge code review (subagent)** — surfaced 2 CRITICAL +
|
||||
4 HIGH + 3 MEDIUM + 3 LOW findings.
|
||||
- [x] **All review findings fixed** (commits `7736bc6` + `0e3ae78`):
|
||||
- CRITICAL #1: missing `url_scheme.py` / `net_classify.py`
|
||||
committed (4 files / 557 lines).
|
||||
- CRITICAL #2: `update_device` no longer re-encrypts secrets in
|
||||
memory via the `to_dict()` round-trip (uses `vars()` directly).
|
||||
- HIGH #3: `nanoleaf_token` / `hue_username` / `hue_client_key`
|
||||
stripped from `DeviceResponse`; replaced with paired-flag
|
||||
booleans. Frontend updated.
|
||||
- HIGH #4: `validate_lan_host()` rejects literal public IPs at
|
||||
each driver's `validate_device` + `pair_device`.
|
||||
- HIGH #5: `_dec()` failures clear the field and log, not crash
|
||||
the row.
|
||||
- HIGH #6: update route now rstrip's URL for all device types.
|
||||
- MEDIUM #7: Govee discovery serialized via `asyncio.Lock`.
|
||||
- MEDIUM #8: Nanoleaf mDNS browser cleanup moved to `finally`.
|
||||
- MEDIUM #9: pair endpoint sanitizes URL userinfo in logs.
|
||||
- LOW: Nanoleaf `.port` property added; pair-then-create E2E test
|
||||
added.
|
||||
- Tests: 1379 pass (+21 regression tests).
|
||||
|
||||
@@ -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.4.2"
|
||||
versionName = "0.7.0"
|
||||
|
||||
ndk {
|
||||
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
||||
|
||||
+4
-1
@@ -162,8 +162,11 @@ Section "Desktop shortcut" SecDesktop
|
||||
SectionEnd
|
||||
|
||||
Section "Start with Windows" SecAutostart
|
||||
; Pass --autostart so the VBS sets LEDGRAB_AUTOSTART=1 and the app suppresses
|
||||
; the browser auto-open on Windows login. Manual launches (desktop / start
|
||||
; menu) don't pass the arg, so they keep opening the WebUI tab.
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}" --autostart' \
|
||||
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
||||
# Refactor Plan: Per-Provider Typed Device Configs
|
||||
|
||||
**Status:** Planned, not started.
|
||||
**Target branch:** `refactor/device-typed-configs`
|
||||
**Intended executor:** Sonnet agent (one phase per invocation; human review between phases).
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the flat [`DeviceInfo`](../../server/src/ledgrab/core/processing/target_processor.py) dataclass (and the `**kwargs`-based `LEDDeviceProvider.create_client(url, **kwargs)` contract) with a **discriminated union of per-provider config dataclasses**. Each provider owns its config type and reads typed fields instead of guessing kwargs.
|
||||
|
||||
## Motivation
|
||||
|
||||
Current pain points:
|
||||
|
||||
- [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) unpacks ~21 fields by hand into `create_led_client(**kwargs)`.
|
||||
- Every provider's `create_client` starts with `kwargs.get("x", default)` — no type safety, no IDE hints, no way to know at a glance which fields a provider actually uses.
|
||||
- Adding a new per-device-type field requires threading it through `Device` → `DeviceInfo` → `_DEVICE_FIELD_DEFAULTS` → call-site unpacking → kwargs bag → provider.
|
||||
- Fields leak across device types (a WLED device carries `ble_govee_key=""` at runtime for no reason).
|
||||
|
||||
## Scope guardrails
|
||||
|
||||
- **Storage schema (SQLite) unchanged.** Columns stay, dead-for-this-type fields stay, no destructive migration.
|
||||
- **Frontend HTML/TS unchanged in phases 1-4.** It already branches on `device_type` with show/hide logic. Frontend changes are deferred to Phase 5.
|
||||
- **API schemas are last.** Phase 5 converts `DeviceCreate`/`DeviceUpdate`/`DeviceResponse` to a Pydantic v2 discriminated union. This is the only breaking external change and can be deferred indefinitely if needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Config hierarchy (foundation, non-breaking)
|
||||
|
||||
### Create
|
||||
|
||||
**File:** `server/src/ledgrab/core/devices/device_config.py`
|
||||
|
||||
Pattern:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Literal, Optional, Union
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BaseDeviceConfig:
|
||||
device_id: str
|
||||
device_url: str
|
||||
led_count: int
|
||||
software_brightness: int = 255
|
||||
test_mode_active: bool = False
|
||||
auto_shutdown: bool = False
|
||||
rgbw: bool = False
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WLEDConfig(BaseDeviceConfig):
|
||||
device_type: Literal["wled"] = "wled"
|
||||
use_ddp: bool = False
|
||||
|
||||
# ... one @dataclass(frozen=True) per provider
|
||||
```
|
||||
|
||||
### Config field inventory
|
||||
|
||||
Base: `device_id`, `device_url`, `led_count`, `software_brightness`, `test_mode_active`, `auto_shutdown`, `rgbw`.
|
||||
|
||||
| Config | Extra fields beyond Base |
|
||||
| -------------- | ------------------------ |
|
||||
| WLEDConfig | `use_ddp: bool = False` |
|
||||
| AdalightConfig | `baud_rate: Optional[int] = None` |
|
||||
| AmbiLEDConfig | `baud_rate: Optional[int] = None` |
|
||||
| DMXConfig | `dmx_protocol`, `dmx_start_universe`, `dmx_start_channel` |
|
||||
| ESPNowConfig | `baud_rate`, `espnow_peer_mac`, `espnow_channel` |
|
||||
| HueConfig | `hue_username`, `hue_client_key`, `hue_entertainment_group_id` |
|
||||
| SPIConfig | `spi_speed_hz`, `spi_led_type` |
|
||||
| ChromaConfig | `chroma_device_type` |
|
||||
| GameSenseConfig| `gamesense_device_type` |
|
||||
| BLEConfig | `ble_family`, `ble_govee_key` |
|
||||
| GroupConfig | `group_mode`, `group_device_ids` (**no `device_store` here** — see Phase 2) |
|
||||
| OpenRGBConfig | `zone_mode` |
|
||||
| MockConfig | `send_latency_ms: int = 0` |
|
||||
| DemoConfig | `send_latency_ms: int = 0` |
|
||||
| MQTTConfig | (none) |
|
||||
| WSConfig | (none) |
|
||||
| USBHIDConfig | (none — `hid_usage_page` is parsed from the URL, not config) |
|
||||
|
||||
```python
|
||||
DeviceConfig = Union[
|
||||
WLEDConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, ESPNowConfig,
|
||||
HueConfig, SPIConfig, ChromaConfig, GameSenseConfig, BLEConfig,
|
||||
GroupConfig, MQTTConfig, WSConfig, USBHIDConfig, OpenRGBConfig,
|
||||
MockConfig, DemoConfig,
|
||||
]
|
||||
```
|
||||
|
||||
### Add
|
||||
|
||||
**`Device.to_config() -> DeviceConfig`** in [server/src/ledgrab/storage/device_store.py](../../server/src/ledgrab/storage/device_store.py) (around lines 14-97 where `Device` lives).
|
||||
|
||||
- Dispatches on `self.device_type`.
|
||||
- Constructs the right subclass, pulling only relevant columns.
|
||||
- Ignores columns that don't apply to the type.
|
||||
- This is the **only** place that knows the flat→typed mapping.
|
||||
|
||||
### Do NOT touch in Phase 1
|
||||
|
||||
- Provider signatures (still `create_client(self, url, **kwargs)`).
|
||||
- `create_led_client` factory.
|
||||
- Any call site.
|
||||
- `DeviceInfo` itself.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- New unit test `server/tests/core/devices/test_device_config.py`:
|
||||
- For each provider, build a `Device` with that `device_type`, call `to_config()`, assert right subclass and right fields.
|
||||
- Edge case: extra/irrelevant Device fields must not leak into the wrong config type.
|
||||
- `cd server && ruff check src/ tests/ --fix` — green.
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green (existing tests untouched, new test passes).
|
||||
- `cd server && npx tsc --noEmit` — green (no TS impact this phase, just a sanity check).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 + Phase 3 — Provider API migration + call-site migration (single PR)
|
||||
|
||||
**These must land in one commit** because the provider signature change would otherwise break the 3 call sites immediately.
|
||||
|
||||
### Change the abstract base
|
||||
|
||||
[server/src/ledgrab/core/devices/led_client.py](../../server/src/ledgrab/core/devices/led_client.py):
|
||||
|
||||
```python
|
||||
class LEDDeviceProvider(ABC):
|
||||
@abstractmethod
|
||||
def create_client(self, config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient: ...
|
||||
```
|
||||
|
||||
`ProviderDeps` is a tiny new dataclass:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDeps:
|
||||
device_store: "DeviceStore"
|
||||
# Add future cross-cutting runtime deps here (http_client, etc.)
|
||||
```
|
||||
|
||||
`create_led_client`:
|
||||
|
||||
```python
|
||||
def create_led_client(config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient:
|
||||
return get_provider(config.device_type).create_client(config, deps=deps)
|
||||
```
|
||||
|
||||
### Update every provider (17 files)
|
||||
|
||||
- Narrow signature per provider: e.g. `WLEDDeviceProvider.create_client(self, config: WLEDConfig, *, deps: ProviderDeps)`.
|
||||
- Drop all `kwargs.get("x")` lookups — read typed fields directly.
|
||||
- Providers that don't need `deps` just ignore it.
|
||||
- **GroupDeviceProvider** is the only current consumer of `deps`: reads `deps.device_store`.
|
||||
|
||||
### Call sites (3)
|
||||
|
||||
1. [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) lines ~120-148 — the 21-field unpacking. Replace with:
|
||||
```python
|
||||
config = device.to_config()
|
||||
self._led_client = create_led_client(config, deps=self._provider_deps)
|
||||
```
|
||||
`self._provider_deps` is plumbed in from `ProcessorManager` when the target processor is constructed.
|
||||
2. [server/src/ledgrab/core/processing/device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78 — minimal test-mode client. Build a synthetic config via a helper `_minimal_config_for_test_mode(device)` (keeps just `device_id`, `device_url`, `led_count`, `baud_rate`) and pass it.
|
||||
3. [server/src/ledgrab/core/devices/group_client.py](../../server/src/ledgrab/core/devices/group_client.py) lines 47-70 — child client construction inside the group. Same pattern: `child_config = child_device.to_config()`; pass `deps` through.
|
||||
|
||||
### Delete
|
||||
|
||||
- `DeviceInfo` dataclass in [server/src/ledgrab/core/processing/target_processor.py](../../server/src/ledgrab/core/processing/target_processor.py) lines 71-109.
|
||||
- `ProcessorManager._get_device_info()` and `_DEVICE_FIELD_DEFAULTS` in [server/src/ledgrab/core/processing/processor_manager.py](../../server/src/ledgrab/core/processing/processor_manager.py) lines 230-275 — `Device.to_config()` subsumes this. Verify no other callers via `ast-index usages "_get_device_info"`.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `ast-index search "device_info\."` — no hits in non-test code.
|
||||
- `ast-index search "DeviceInfo"` — no hits outside archival comments.
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all tests pass.
|
||||
- Manual smoke: start server, create a WLED device, start processing, verify LEDs update (or mock output shows frames).
|
||||
- `cd server && ruff check src/ tests/ --fix` — green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Test migration
|
||||
|
||||
Update these files:
|
||||
|
||||
- `server/tests/storage/test_device_store.py` — add `to_config()` cases per device type.
|
||||
- `server/tests/api/routes/test_devices_routes.py` — should be mostly untouched (API schemas still flat until Phase 5).
|
||||
- `server/tests/e2e/test_device_flow.py` — update internal assertions only if they touch `DeviceInfo` directly.
|
||||
- `server/tests/test_group_device.py` — construct child clients with `GroupConfig`.
|
||||
- Any fixture helper that builds a fake `DeviceInfo` — migrate to the right `*Config` subclass.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all green.
|
||||
- Coverage of `device_config.py` and `Device.to_config()` ≥ 90%.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — API discriminated union (OPTIONAL, separate PR)
|
||||
|
||||
**Do not start until Phases 1-4 are merged and stable.** Flag this to the human before beginning. This is the only phase with an externally breaking change.
|
||||
|
||||
### Backend
|
||||
|
||||
[server/src/ledgrab/api/schemas/devices.py](../../server/src/ledgrab/api/schemas/devices.py) — replace flat `DeviceCreate`/`DeviceUpdate` with Pydantic v2 tagged unions:
|
||||
|
||||
```python
|
||||
class WLEDDeviceCreate(BaseModel):
|
||||
device_type: Literal["wled"]
|
||||
name: str
|
||||
url: str
|
||||
led_count: int
|
||||
use_ddp: bool = False
|
||||
# ... base fields only
|
||||
|
||||
DeviceCreate = Annotated[
|
||||
Union[WLEDDeviceCreate, AdalightDeviceCreate, ...],
|
||||
Field(discriminator="device_type"),
|
||||
]
|
||||
```
|
||||
|
||||
Add `model_config = ConfigDict(extra="ignore")` on each union member for **one release cycle** so existing clients (frontend, HAOS integration, curl scripts) that send extra fields don't 422 immediately. Add a deprecation note and tighten to `extra="forbid"` in a follow-up.
|
||||
|
||||
### Frontend
|
||||
|
||||
- [server/src/ledgrab/static/js/features/devices.ts](../../server/src/ledgrab/static/js/features/devices.ts) and related — when building the POST/PATCH body, scope the payload to the selected `device_type` using the show/hide knowledge already in `device-discovery.ts`.
|
||||
- **No plain `<select>` elements** — any new pickers use IconSelect or EntitySelect (see root CLAUDE.md UI rules).
|
||||
|
||||
### Tests
|
||||
|
||||
- Update `test_devices_routes.py` to assert discriminated union rejection of mismatched shapes.
|
||||
- Add round-trip tests: create device of each type via API → fetch → compare fields.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green.
|
||||
- `cd server && npx tsc --noEmit && npm run build` — green.
|
||||
- Manual smoke for at least 3 device types (WLED, DMX, Hue) — create, edit, delete via UI.
|
||||
- HAOS integration still works against the server (spot-check; not automated).
|
||||
|
||||
---
|
||||
|
||||
## Conventions the implementing agent must follow
|
||||
|
||||
- **Project task tracker is `TODO.md`** — check the "Refactor: Per-Provider Device Configs" section, tick boxes as phases land. Do **not** use the `TodoWrite` tool.
|
||||
- **Auto-restart after Python changes.** See [contexts/server-operations.md](../../contexts/server-operations.md).
|
||||
- **No commits without explicit user approval.** Present each phase's diff for review first.
|
||||
- **Pre-commit gate every phase:**
|
||||
- `cd server && ruff check src/ tests/ --fix`
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q`
|
||||
- Phase 5 additionally: `cd server && npx tsc --noEmit && npm run build`
|
||||
- **No plain `<select>`** — Phase 5 uses IconSelect / EntitySelect.
|
||||
- **Android parity:** if you add any new runtime dep to `server/pyproject.toml`, update `android/app/build.gradle.kts` per the root [CLAUDE.md](../../CLAUDE.md) "Android Dependency Sync" section. This refactor should not need any new deps.
|
||||
- **Data migration policy:** storage schema is unchanged, so no JSON-file migration is needed. But if you rename any serialized field during `to_dict`/`from_dict`, add migration logic per the root [CLAUDE.md](../../CLAUDE.md) "Data Migration Policy" section.
|
||||
- **Use `ast-index`** for code search (`ast-index search`, `ast-index usages`, `ast-index callers`, `ast-index class`). Fall back to Grep only for regex/string-literal/comment searches.
|
||||
- **Never run `cd` in Bash.** Use absolute paths or the project-relative `cd server && <cmd>` idiom (one-shot, same invocation).
|
||||
|
||||
## Known risks
|
||||
|
||||
1. **Frozen dataclass + inheritance + defaults** — Python's `@dataclass(frozen=True)` with inheritance requires every subclass field to have a default if any parent field does. Base has defaulted fields. Verify in Phase 1. If it breaks, use `kw_only=True` (Python 3.10+).
|
||||
2. **`use_ddp` origin** — currently inferred from `self._protocol == "ddp"` at the call site, not from Device storage. Options: add a column (schema change, more work), **or** keep inference logic inside `Device.to_config()` (recommended — no schema change). Prefer the latter.
|
||||
3. **Test-mode minimal client** ([device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78) may not have all `BaseDeviceConfig` fields available. Build a synthetic config via a named helper; do not leak the hack into `Device.to_config()`.
|
||||
4. **Group `device_store` import cycle** — `GroupConfig` must **not** hold `device_store` (would pull storage into the config module). `ProviderDeps` is the deliberate cut.
|
||||
5. **BLE optional import** — `BLEDeviceProvider` is conditionally registered (see [led_client.py](../../server/src/ledgrab/core/devices/led_client.py) lines 321-330). Ensure `BLEConfig` still imports cleanly even when `bleak` is absent — put `BLEConfig` in `device_config.py` (not in `ble_provider.py`) so it's always importable.
|
||||
|
||||
## Deliverables per phase
|
||||
|
||||
1. Branch: `refactor/device-typed-configs`.
|
||||
2. One commit per phase, conventional-commit messages:
|
||||
- `refactor(devices): phase 1 — add DeviceConfig hierarchy`
|
||||
- `refactor(devices): phases 2+3 — typed provider signatures + call-site migration`
|
||||
- `refactor(devices): phase 4 — test migration to typed configs`
|
||||
- `refactor(devices): phase 5 — API discriminated union` (separate PR)
|
||||
3. Phase-by-phase diffs presented for user review **before** each commit.
|
||||
4. Final PR body linking all phases, with manual test plan per device type touched.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,18 @@ server:
|
||||
# For LAN access, add your machine's IP, e.g. "http://192.168.1.100:8080"
|
||||
cors_origins:
|
||||
- "http://localhost:8080"
|
||||
- "http://192.168.2.100:8080"
|
||||
|
||||
auth:
|
||||
# API keys — required for any non-loopback (LAN) request.
|
||||
# When empty:
|
||||
# When empty (default):
|
||||
# - loopback (127.0.0.1, ::1, localhost) requests are allowed anonymously
|
||||
# - LAN requests are REJECTED with 401 (security default)
|
||||
# To enable LAN access, add one or more label: "api-key" entries below
|
||||
# and send `Authorization: Bearer <api-key>` with each request.
|
||||
# Generate secure keys: openssl rand -hex 32
|
||||
# To enable LAN access, uncomment the example below and replace the value
|
||||
# with a secret you generated yourself (e.g. `openssl rand -hex 32`).
|
||||
# The previous default `dev: "development-key-change-in-production"` has
|
||||
# been removed — it shipped as a publicly-known token and any deployment
|
||||
# that still uses it grants full LAN access to anyone on the network.
|
||||
api_keys:
|
||||
dev: "development-key-change-in-production"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+48
@@ -14,6 +14,9 @@
|
||||
"marked": "^17.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/big-shoulders-display": "^5.2.5",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
@@ -434,6 +437,33 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/big-shoulders-display": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
|
||||
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/manrope": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
|
||||
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
@@ -704,6 +734,24 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@fontsource-variable/big-shoulders-display": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
|
||||
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
|
||||
"dev": true
|
||||
},
|
||||
"@fontsource-variable/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@fontsource-variable/manrope": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
|
||||
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
|
||||
"dev": true
|
||||
},
|
||||
"@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/big-shoulders-display": "^5.2.5",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ledgrab"
|
||||
version = "0.4.2"
|
||||
version = "0.7.0"
|
||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
authors = [
|
||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||
@@ -117,3 +117,11 @@ target-version = ['py311']
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# E + F are ruff's defaults; UP007 + UP045 enforce PEP-604 `X | Y` and
|
||||
# `T | None` style so we don't drift back to the legacy `Union[X, Y]` /
|
||||
# `Optional[T]` imports the REVIEW_TODO mechanical sweep removed.
|
||||
# Recent ruff versions split the rule — UP007 covers `Union`, UP045
|
||||
# covers `Optional`.
|
||||
extend-select = ["UP007", "UP045"]
|
||||
|
||||
+86
-28
@@ -288,23 +288,72 @@ $pythonExe = $resolvedPython
|
||||
Write-Info "Starting $Module on port $Port..."
|
||||
if ($SkipBrowser) { $env:LEDGRAB_RESTART = '1' }
|
||||
|
||||
# Redirect the child's stdout/stderr to a log file. Without this, inheriting
|
||||
# the parent shell's handles via Start-Process -WindowStyle Hidden can cause
|
||||
# the child to exit immediately when those handles aren't real console fds
|
||||
# (e.g. when restart.ps1 is driven from WSL/Git-Bash).
|
||||
$logPath = Join-Path $env:TEMP ("ledgrab-{0}-{1}.log" -f $Module, $Port)
|
||||
$errPath = "$logPath.err"
|
||||
# Launch python.exe directly with no parent-handle inheritance. We used to
|
||||
# wrap it in `cmd /c python ... 1>log 2>err` so the parent powershell could
|
||||
# tail crash logs, but that left an empty cmd.exe window hanging around for
|
||||
# the full server lifetime (cmd had to live to hold the redirect handles).
|
||||
# Instead, let python claim its own console window — the user sees the live
|
||||
# server log there, and there's no spurious cmd window.
|
||||
#
|
||||
# Why WMI Win32_Process.Create rather than Start-Process or
|
||||
# [Diagnostics.Process]::Start? Both of those go through CreateProcess with
|
||||
# bInheritHandles=true, which leaks the parent shell's pipe handles into
|
||||
# the new Python process. When the caller is Git-Bash (`restart.ps1 |
|
||||
# tail -10`), the bash pipe then stays open for the full server lifetime,
|
||||
# hanging the bash invocation even after powershell exits. WMI's
|
||||
# Win32_Process.Create uses CreateProcess with bInheritHandles=FALSE.
|
||||
|
||||
$argList = @()
|
||||
$argList += $launchArgs
|
||||
$argList += @('-m', $Module)
|
||||
$startedProc = Start-Process -FilePath $pythonExe `
|
||||
-ArgumentList $argList `
|
||||
-WorkingDirectory $ServerRoot `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $logPath `
|
||||
-RedirectStandardError $errPath `
|
||||
-PassThru
|
||||
$startedPid = $startedProc.Id
|
||||
|
||||
# Quote each arg defensively in case a future caller adds whitespace.
|
||||
function Quote-CmdArg {
|
||||
param([string]$Arg)
|
||||
if ($Arg -match '[\s"]') {
|
||||
return '"' + ($Arg -replace '"', '\"') + '"'
|
||||
}
|
||||
return $Arg
|
||||
}
|
||||
$quotedArgs = ($argList | ForEach-Object { Quote-CmdArg $_ }) -join ' '
|
||||
$pyQ = Quote-CmdArg $pythonExe
|
||||
|
||||
$cmdLine = $pyQ + ' ' + $quotedArgs
|
||||
|
||||
# Win32_Process.Create starts detached with no parent-handle inheritance.
|
||||
# Returns @{ ProcessId; ReturnValue (0 = success) }.
|
||||
# Title sets the visible console-window title so the user can tell at a
|
||||
# glance which server the window belongs to (useful when running real +
|
||||
# demo side by side on different ports).
|
||||
$startupInfo = New-CimInstance -ClassName Win32_ProcessStartup `
|
||||
-ClientOnly `
|
||||
-Property @{ Title = "LedGrab - $Module (port $Port)" }
|
||||
$wmiResult = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
||||
CommandLine = $cmdLine
|
||||
CurrentDirectory = $ServerRoot
|
||||
ProcessStartupInformation = $startupInfo
|
||||
} -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $wmiResult -or $wmiResult.ReturnValue -ne 0) {
|
||||
Write-Warning "WMI Win32_Process.Create failed (ReturnValue=$($wmiResult.ReturnValue)); falling back to Start-Process"
|
||||
# Fallback path — Start-Process inherits parent handles, so a piped
|
||||
# caller may hang. Acceptable here because this branch only runs when
|
||||
# WMI itself is broken (very rare).
|
||||
$startedProc = Start-Process -FilePath $pythonExe `
|
||||
-ArgumentList $argList `
|
||||
-WorkingDirectory $ServerRoot -PassThru
|
||||
$startedPid = if ($startedProc) { $startedProc.Id } else { 0 }
|
||||
} else {
|
||||
$startedPid = [int]$wmiResult.ProcessId
|
||||
}
|
||||
|
||||
# Confirm the process is actually our server (defensive — WMI sometimes
|
||||
# returns a PID for a transient ancestor on heavily loaded boxes).
|
||||
Start-Sleep -Milliseconds 250
|
||||
if (-not (Get-Process -Id $startedPid -ErrorAction SilentlyContinue)) {
|
||||
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
|
||||
if ($rescanned) { $startedPid = $rescanned.ProcessId } else { $startedPid = 0 }
|
||||
}
|
||||
|
||||
# ---- Poll readiness --------------------------------------------------------
|
||||
|
||||
@@ -316,28 +365,37 @@ $deadline = (Get-Date).AddSeconds($StartupTimeoutSec)
|
||||
$ready = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
# Bail early if the process has already exited — something went wrong.
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) { break }
|
||||
if ($startedPid -gt 0) {
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) {
|
||||
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
|
||||
if ($rescanned) { $startedPid = $rescanned.ProcessId } else { break }
|
||||
}
|
||||
} else {
|
||||
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
|
||||
if ($rescanned) { $startedPid = $rescanned.ProcessId }
|
||||
}
|
||||
if (Test-PortOpen -Port $Port) { $ready = $true; break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
if ($ready) {
|
||||
Write-Info "Server ready on port $Port (PID $startedPid)"
|
||||
if ($startedPid -gt 0) {
|
||||
Write-Info "Server ready on port $Port (PID $startedPid)"
|
||||
} else {
|
||||
Write-Info "Server ready on port $Port"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) {
|
||||
Write-Warning "Server process $startedPid exited before binding port $Port"
|
||||
} else {
|
||||
Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s"
|
||||
}
|
||||
if (Test-Path $errPath) {
|
||||
$tail = Get-Content $errPath -Tail 20 -ErrorAction SilentlyContinue
|
||||
if ($tail) {
|
||||
Write-Warning "Last stderr lines from $errPath :"
|
||||
$tail | ForEach-Object { Write-Warning " $_" }
|
||||
if ($startedPid -gt 0) {
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) {
|
||||
Write-Warning "Server process $startedPid exited before binding port $Port (check the server console window for the error)"
|
||||
} else {
|
||||
Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s"
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Could not locate server process; port $Port did not bind within ${StartupTimeoutSec}s"
|
||||
}
|
||||
exit 1
|
||||
|
||||
@@ -10,6 +10,15 @@ Set procEnv = WshShell.Environment("Process")
|
||||
procEnv("PYTHONPATH") = appRoot & "\app\src"
|
||||
procEnv("LEDGRAB_CONFIG_PATH") = appRoot & "\app\config\default_config.yaml"
|
||||
|
||||
' If launched as Windows autostart (via the SMSTARTUP shortcut), suppress the
|
||||
' browser auto-open. Manual launches (desktop / start menu) pass no args.
|
||||
For Each arg In WScript.Arguments
|
||||
If arg = "--autostart" Then
|
||||
procEnv("LEDGRAB_AUTOSTART") = "1"
|
||||
Exit For
|
||||
End If
|
||||
Next
|
||||
|
||||
' Use embedded python.exe (NOT pythonw.exe) with WindowStyle=0.
|
||||
' Same pattern as the Media Server sibling app.
|
||||
embeddedPython = appRoot & "\python\python.exe"
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
"""LED Grab - Ambient lighting based on screen content."""
|
||||
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
from pathlib import Path
|
||||
|
||||
# Fallback version — kept in sync with pyproject.toml. MUST match the
|
||||
# version declared there on every release. The Windows installer build
|
||||
# (build/build-dist.ps1) also patches this literal to the resolved build
|
||||
# version, so any drift here is corrected for bundled distributions.
|
||||
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
|
||||
# on Android, where the source is included directly via source sets, or
|
||||
# in the Windows bundle where the installed dist-info is stripped).
|
||||
_FALLBACK_VERSION = "0.4.2"
|
||||
# Fallback version — patched at build time by build/build-dist.ps1 so the
|
||||
# bundled Windows distribution reports the release version (the installer
|
||||
# strips ledgrab-*.dist-info, so importlib.metadata fails there).
|
||||
# In dev (running from source without `pip install -e .`) and on Android
|
||||
# (Chaquopy embeds the source directly with no dist-info), we additionally
|
||||
# read pyproject.toml so the version is always correct without manual sync.
|
||||
_FALLBACK_VERSION = "0.7.0"
|
||||
|
||||
try:
|
||||
__version__ = version("ledgrab")
|
||||
except PackageNotFoundError:
|
||||
__version__ = _FALLBACK_VERSION
|
||||
|
||||
def _read_pyproject_version() -> str | None:
|
||||
"""Read version from pyproject.toml (server/pyproject.toml relative to this file).
|
||||
|
||||
Returns None if the file is absent (typical for installed/bundled distributions
|
||||
where pyproject.toml isn't shipped) or unreadable.
|
||||
"""
|
||||
try:
|
||||
# __init__.py -> ledgrab/ -> src/ -> server/
|
||||
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
if not pyproject.is_file():
|
||||
return None
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError:
|
||||
return None
|
||||
with pyproject.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
v = data.get("project", {}).get("version")
|
||||
return v if isinstance(v, str) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Prefer pyproject.toml when it sits next to the source (dev checkout). This
|
||||
# avoids stale `pip install -e .` dist-info pinning an older version after a
|
||||
# bump. When pyproject.toml isn't shipped (installed packages, Windows bundle,
|
||||
# Android), fall back to importlib.metadata, then the patched literal.
|
||||
_live = _read_pyproject_version()
|
||||
if _live:
|
||||
__version__ = _live
|
||||
else:
|
||||
try:
|
||||
__version__ = version("ledgrab")
|
||||
except PackageNotFoundError:
|
||||
__version__ = _FALLBACK_VERSION
|
||||
|
||||
__author__ = "Alexei Dolgolyov"
|
||||
__email__ = "dolgolyov.alexei@gmail.com"
|
||||
|
||||
@@ -6,12 +6,15 @@ shows a system-tray icon with **Show UI** / **Exit** actions.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
def _fix_embedded_tcl_paths() -> None:
|
||||
@@ -40,6 +43,8 @@ from ledgrab.config import get_config # noqa: E402
|
||||
from ledgrab.server_ref import set_server, set_tray # noqa: E402
|
||||
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
|
||||
from ledgrab.utils import setup_logging, get_logger # noqa: E402
|
||||
from ledgrab.utils.platform import is_windows # noqa: E402
|
||||
from ledgrab.utils.win_shutdown import WindowsShutdownGuard # noqa: E402
|
||||
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
@@ -54,9 +59,25 @@ def _run_server(server: uvicorn.Server) -> None:
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
|
||||
def _open_browser(port: int, delay: float = 2.0) -> None:
|
||||
"""Open the UI in the default browser after a short delay."""
|
||||
time.sleep(delay)
|
||||
def _wait_for_server(port: int, timeout: float = 30.0, interval: float = 0.25) -> bool:
|
||||
"""Poll /health until the server responds or *timeout* seconds elapse."""
|
||||
url = f"http://localhost:{port}/health"
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urlopen(url, timeout=1) as resp: # noqa: S310 - localhost only
|
||||
if 200 <= resp.status < 500:
|
||||
return True
|
||||
except (URLError, ConnectionError, OSError, TimeoutError):
|
||||
pass
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
def _open_browser(port: int) -> None:
|
||||
"""Open the UI in the default browser once the server is ready."""
|
||||
if not _wait_for_server(port):
|
||||
logger.warning("Server did not become ready in time; opening browser anyway")
|
||||
webbrowser.open(f"http://localhost:{port}")
|
||||
|
||||
|
||||
@@ -65,6 +86,16 @@ def _is_restart() -> bool:
|
||||
return os.environ.get("LEDGRAB_RESTART", "") == "1"
|
||||
|
||||
|
||||
def _is_autostart() -> bool:
|
||||
"""Detect if launched via the Windows autostart shortcut."""
|
||||
return os.environ.get("LEDGRAB_AUTOSTART", "") == "1"
|
||||
|
||||
|
||||
def _should_skip_browser() -> bool:
|
||||
"""Skip auto-opening the browser on restarts and on Windows login autostart."""
|
||||
return _is_restart() or _is_autostart()
|
||||
|
||||
|
||||
def _check_port(host: str, port: int) -> None:
|
||||
"""Exit with a clear message if the port is already in use."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
@@ -89,10 +120,22 @@ def main() -> None:
|
||||
server = uvicorn.Server(uv_config)
|
||||
set_server(server)
|
||||
|
||||
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
|
||||
# ``shutdown_complete`` once it has stopped targets and checkpointed the
|
||||
# DB; the Windows guard waits on that event before letting the OS finish
|
||||
# ending the session. Without this, the entire shutdown lifespan never
|
||||
# runs on PC reboot — devices stay on and the SQLite WAL is lost.
|
||||
guard = _install_os_shutdown_guard(server)
|
||||
|
||||
use_tray = PYSTRAY_AVAILABLE and (sys.platform == "win32" or _force_tray())
|
||||
|
||||
if use_tray:
|
||||
logger.info("Starting with system tray icon")
|
||||
# Install signal handlers BEFORE starting the uvicorn thread so a
|
||||
# SIGINT/SIGBREAK during startup still triggers a clean shutdown.
|
||||
# We do NOT install them on the no-tray path because uvicorn's
|
||||
# ``server.run()`` overwrites SIGINT/SIGTERM with its own handlers.
|
||||
_install_signal_handlers(server)
|
||||
|
||||
# Uvicorn in a background thread
|
||||
server_thread = threading.Thread(
|
||||
@@ -102,8 +145,8 @@ def main() -> None:
|
||||
)
|
||||
server_thread.start()
|
||||
|
||||
# Browser after a short delay (skip on restart — user already has a tab)
|
||||
if not _is_restart():
|
||||
# Browser after a short delay (skip on restart and on Windows login autostart)
|
||||
if not _should_skip_browser():
|
||||
threading.Thread(
|
||||
target=_open_browser,
|
||||
args=(config.server.port,),
|
||||
@@ -119,12 +162,20 @@ def main() -> None:
|
||||
set_tray(tray)
|
||||
tray.run()
|
||||
|
||||
# Tray exited — wait for server to finish its graceful shutdown
|
||||
server_thread.join(timeout=10)
|
||||
# Tray exited — wait for server to finish its graceful shutdown.
|
||||
# Use a longer join than the lifespan's own ~18 s budget so we don't
|
||||
# cut the DB checkpoint short on a slow disk.
|
||||
server_thread.join(timeout=20)
|
||||
if guard is not None:
|
||||
guard.stop()
|
||||
else:
|
||||
if not PYSTRAY_AVAILABLE:
|
||||
logger.info("System tray not available (install pystray for tray support)")
|
||||
server.run()
|
||||
try:
|
||||
server.run()
|
||||
finally:
|
||||
if guard is not None:
|
||||
guard.stop()
|
||||
|
||||
|
||||
def _request_shutdown(server: uvicorn.Server) -> None:
|
||||
@@ -132,6 +183,57 @@ def _request_shutdown(server: uvicorn.Server) -> None:
|
||||
server.should_exit = True
|
||||
|
||||
|
||||
def _install_os_shutdown_guard(server: uvicorn.Server) -> "WindowsShutdownGuard | None":
|
||||
"""Install the OS-shutdown safety net (Windows only).
|
||||
|
||||
Returns the guard so the caller can ``stop()`` it on normal exit, or
|
||||
``None`` on platforms where no guard is needed.
|
||||
"""
|
||||
if not is_windows():
|
||||
return None
|
||||
|
||||
# ``shutdown_state`` is a leaf module — importing it does NOT pull in
|
||||
# ``ledgrab.main`` and its global stores. uvicorn loads ``main`` lazily
|
||||
# via the import string ``"ledgrab.main:app"`` once it starts serving.
|
||||
from ledgrab.shutdown_state import shutdown_complete
|
||||
|
||||
guard = WindowsShutdownGuard(
|
||||
on_shutdown=lambda: _request_shutdown(server),
|
||||
shutdown_complete=shutdown_complete,
|
||||
)
|
||||
if guard.start():
|
||||
logger.info("Windows shutdown guard installed")
|
||||
else:
|
||||
logger.warning("Windows shutdown guard failed to start")
|
||||
return guard
|
||||
|
||||
|
||||
def _install_signal_handlers(server: uvicorn.Server) -> None:
|
||||
"""Catch terminal/admin shutdown signals and trigger graceful exit.
|
||||
|
||||
Uvicorn already installs SIGINT/SIGTERM handlers when ``server.run()``
|
||||
is called on the main thread (the no-tray path). For the tray path,
|
||||
uvicorn runs on a background thread and skips signal installation, so
|
||||
we install our own here. SIGBREAK is Windows-specific and fires on
|
||||
Ctrl-Break and in some service-stop scenarios.
|
||||
"""
|
||||
|
||||
def _handler(signum, frame): # noqa: ANN001 - signal handler signature
|
||||
logger.warning("Signal %s received — requesting shutdown", signum)
|
||||
_request_shutdown(server)
|
||||
|
||||
candidates = ["SIGINT", "SIGTERM", "SIGBREAK"]
|
||||
for name in candidates:
|
||||
sig = getattr(signal, name, None)
|
||||
if sig is None:
|
||||
continue
|
||||
try:
|
||||
signal.signal(sig, _handler)
|
||||
except (ValueError, OSError) as e:
|
||||
# ValueError: not on main thread; OSError: signal not supported here.
|
||||
logger.debug("Could not install handler for %s: %s", name, e)
|
||||
|
||||
|
||||
def _force_tray() -> bool:
|
||||
"""Allow forcing tray on non-Windows via LEDGRAB_TRAY=1."""
|
||||
import os
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -27,10 +27,12 @@ from .routes.update import router as update_router
|
||||
from .routes.assets import router as assets_router
|
||||
from .routes.home_assistant import router as home_assistant_router
|
||||
from .routes.mqtt import router as mqtt_router
|
||||
from .routes.http_endpoints import router as http_endpoints_router
|
||||
from .routes.game_integration import router as game_integration_router
|
||||
from .routes.audio_processing_templates import router as audio_processing_templates_router
|
||||
from .routes.audio_filters import router as audio_filters_router
|
||||
from .routes.pattern_templates import router as pattern_templates_router
|
||||
from .routes.preferences import router as preferences_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -58,9 +60,11 @@ router.include_router(update_router)
|
||||
router.include_router(assets_router)
|
||||
router.include_router(home_assistant_router)
|
||||
router.include_router(mqtt_router)
|
||||
router.include_router(http_endpoints_router)
|
||||
router.include_router(game_integration_router)
|
||||
router.include_router(audio_processing_templates_router)
|
||||
router.include_router(audio_filters_router)
|
||||
router.include_router(pattern_templates_router)
|
||||
router.include_router(preferences_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -11,13 +11,25 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Security scheme for Bearer token
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
||||
|
||||
# Exceptions that legitimately fire when we try to send / close a WebSocket
|
||||
# that is already shutting down: the peer dropped, the connect-state moved
|
||||
# under us, the underlying socket is gone, the JSON encoder choked, etc.
|
||||
# Keeping this tuple narrow means a genuine programming error (AttributeError,
|
||||
# TypeError) bubbles up to the caller instead of silently disappearing.
|
||||
_WS_SEND_BENIGN_EXC: tuple[type[BaseException], ...] = (
|
||||
WebSocketDisconnect,
|
||||
RuntimeError,
|
||||
ConnectionError,
|
||||
OSError,
|
||||
)
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
@@ -26,15 +38,15 @@ def is_auth_enabled() -> bool:
|
||||
|
||||
|
||||
def _is_loopback(host: str | None) -> bool:
|
||||
"""Return True when *host* is a loopback address."""
|
||||
"""Return True when *host* is a loopback address.
|
||||
|
||||
Delegates to :func:`ledgrab.utils.net_classify.is_loopback` so this
|
||||
auth gate, the SSRF guard in ``safe_source``, and the LAN-default
|
||||
inference in ``url_scheme`` share one classification source.
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
# Strip IPv6 brackets and zone IDs
|
||||
h = host.strip().lower()
|
||||
if h.startswith("[") and h.endswith("]"):
|
||||
h = h[1:-1]
|
||||
h = h.split("%", 1)[0]
|
||||
return h in _LOOPBACK_HOSTS
|
||||
return _classify_is_loopback(host)
|
||||
|
||||
|
||||
def verify_api_key(
|
||||
@@ -142,6 +154,23 @@ def require_authenticated(label: str) -> None:
|
||||
WS_AUTH_CLOSE_CODE = 4401
|
||||
|
||||
|
||||
WS_ORIGIN_CLOSE_CODE = 4403
|
||||
"""Close code sent when a WebSocket request fails the Origin allowlist."""
|
||||
|
||||
|
||||
def _is_origin_allowed(origin: str | None, allowed: list[str]) -> bool:
|
||||
"""Return True when *origin* matches one of the configured CORS origins.
|
||||
|
||||
Non-browser clients (Python scripts, curl) don't send Origin — those are
|
||||
allowed through; the Bearer-token check on the auth handshake is the
|
||||
primary defence in that case. Browsers always set Origin, so this only
|
||||
blocks cross-site WebSocket connection attempts (CSWSH).
|
||||
"""
|
||||
if not origin:
|
||||
return True
|
||||
return origin in set(allowed or [])
|
||||
|
||||
|
||||
async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) -> str | None:
|
||||
"""Accept the WebSocket, then perform first-message auth handshake.
|
||||
|
||||
@@ -152,12 +181,29 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
||||
Returns the caller label on success, ``None`` on failure (connection
|
||||
already closed).
|
||||
"""
|
||||
# Reject cross-site WebSocket attempts before accepting — a browser-based
|
||||
# attacker page cannot forge the Origin header, so an Origin mismatch is
|
||||
# a strong signal even before the token check. Non-browser clients
|
||||
# legitimately omit Origin; those fall through to the auth handshake.
|
||||
config = get_config()
|
||||
origin = websocket.headers.get("origin")
|
||||
if not _is_origin_allowed(origin, config.server.cors_origins):
|
||||
logger.warning(
|
||||
"Rejected WebSocket from origin %r (not in cors_origins)",
|
||||
origin,
|
||||
)
|
||||
try:
|
||||
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
await websocket.accept()
|
||||
label = await verify_ws_auth(websocket, timeout=timeout)
|
||||
if label is None:
|
||||
try:
|
||||
await websocket.close(code=WS_AUTH_CLOSE_CODE)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
return label
|
||||
@@ -221,20 +267,29 @@ async def verify_ws_auth(
|
||||
# Loopback anonymous: no auth message arrived, but none is required.
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_ok"})
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
return None
|
||||
return "anonymous"
|
||||
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
except WebSocketDisconnect:
|
||||
return None
|
||||
except Exception as exc:
|
||||
except (RuntimeError, ConnectionError, OSError) as exc:
|
||||
# The peer hung up mid-handshake or the underlying socket is gone.
|
||||
# Promote anything outside this set to a hard failure with a stack
|
||||
# trace so we can see real bugs (decode errors, type errors, …).
|
||||
logger.debug("WebSocket auth receive error: %s", exc)
|
||||
return None
|
||||
except Exception:
|
||||
# Unexpected — log the full traceback so we can see what we missed
|
||||
# without leaving the connection half-open. Re-raise nothing; the
|
||||
# caller will close on the None return.
|
||||
logger.exception("Unexpected error during WebSocket auth handshake")
|
||||
return None
|
||||
|
||||
# Parse the auth message.
|
||||
try:
|
||||
@@ -244,7 +299,7 @@ async def verify_ws_auth(
|
||||
await websocket.send_json(
|
||||
{"type": "auth_error", "reason": "invalid JSON in auth message"}
|
||||
)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -253,7 +308,7 @@ async def verify_ws_auth(
|
||||
await websocket.send_json(
|
||||
{"type": "auth_error", "reason": "first message must be {type:'auth'}"}
|
||||
)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -263,7 +318,7 @@ async def verify_ws_auth(
|
||||
await websocket.send_json(
|
||||
{"type": "auth_error", "reason": "token must be a string or null"}
|
||||
)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -280,7 +335,7 @@ async def verify_ws_auth(
|
||||
"reason": "LAN access requires an API key",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -290,13 +345,13 @@ async def verify_ws_auth(
|
||||
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_ok"})
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
return None
|
||||
logger.debug("WebSocket authenticated as: %s", label)
|
||||
return label
|
||||
|
||||
@@ -37,6 +37,7 @@ from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
||||
|
||||
@@ -165,6 +166,10 @@ def get_mqtt_manager() -> MQTTManager:
|
||||
return _get("mqtt_manager", "MQTT manager")
|
||||
|
||||
|
||||
def get_http_endpoint_store() -> HTTPEndpointStore:
|
||||
return _get("http_endpoint_store", "HTTP endpoint store")
|
||||
|
||||
|
||||
def get_audio_processing_template_store() -> AudioProcessingTemplateStore:
|
||||
return _get("audio_processing_template_store", "Audio processing template store")
|
||||
|
||||
@@ -237,6 +242,7 @@ def init_dependencies(
|
||||
game_event_bus: GameEventBus | None = None,
|
||||
mqtt_store: MQTTSourceStore | None = None,
|
||||
mqtt_manager: MQTTManager | None = None,
|
||||
http_endpoint_store: HTTPEndpointStore | None = None,
|
||||
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
|
||||
pattern_template_store: PatternTemplateStore | None = None,
|
||||
):
|
||||
@@ -272,6 +278,7 @@ def init_dependencies(
|
||||
"game_event_bus": game_event_bus,
|
||||
"mqtt_store": mqtt_store,
|
||||
"mqtt_manager": mqtt_manager,
|
||||
"http_endpoint_store": http_endpoint_store,
|
||||
"audio_processing_template_store": audio_processing_template_store,
|
||||
"pattern_template_store": pattern_template_store,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable
|
||||
|
||||
import numpy as np
|
||||
from starlette.websockets import WebSocket
|
||||
@@ -61,8 +61,8 @@ async def stream_capture_test(
|
||||
websocket: WebSocket,
|
||||
engine_factory: Callable,
|
||||
duration: float,
|
||||
pp_filters: Optional[list] = None,
|
||||
preview_width: Optional[int] = None,
|
||||
pp_filters: list | None = None,
|
||||
preview_width: int | None = None,
|
||||
) -> None:
|
||||
"""Run a capture test, streaming intermediate thumbnails and a final full-res frame.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from ledgrab.api.schemas.assets import (
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import get_logger, read_upload_capped
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -93,10 +93,11 @@ async def upload_asset(
|
||||
config = get_config()
|
||||
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > max_size:
|
||||
try:
|
||||
data = await read_upload_capped(file, max_size)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=413,
|
||||
detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
|
||||
)
|
||||
|
||||
@@ -142,6 +143,8 @@ async def update_asset(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
tags=body.tags,
|
||||
icon=body.icon,
|
||||
icon_color=body.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
|
||||
|
||||
@@ -36,6 +36,8 @@ def _apt_to_response(t) -> AudioProcessingTemplateResponse:
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -73,6 +75,8 @@ async def create_audio_processing_template(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_processing_template", "created", template.id)
|
||||
return _apt_to_response(template)
|
||||
@@ -129,6 +133,8 @@ async def update_audio_processing_template(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_processing_template", "updated", template_id)
|
||||
# Hot-update: rebuild filter pipelines for running streams using this template
|
||||
|
||||
@@ -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
|
||||
@@ -46,6 +46,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
device_index=s.device_index,
|
||||
is_loopback=s.is_loopback,
|
||||
audio_template_id=s.audio_template_id,
|
||||
@@ -57,6 +59,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
audio_source_id=s.audio_source_id,
|
||||
audio_processing_template_id=s.audio_processing_template_id,
|
||||
),
|
||||
@@ -75,6 +79,8 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
device_index=getattr(source, "device_index", -1),
|
||||
is_loopback=getattr(source, "is_loopback", True),
|
||||
audio_template_id=getattr(source, "audio_template_id", None),
|
||||
@@ -85,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),
|
||||
|
||||
@@ -53,6 +53,8 @@ async def list_audio_templates(
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
for t in templates
|
||||
]
|
||||
@@ -81,6 +83,8 @@ async def create_audio_template(
|
||||
engine_config=data.engine_config,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_template", "created", template.id)
|
||||
return AudioTemplateResponse(
|
||||
@@ -92,6 +96,8 @@ async def create_audio_template(
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
icon=getattr(template, "icon", "") or "",
|
||||
icon_color=getattr(template, "icon_color", "") or "",
|
||||
)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -127,6 +133,8 @@ async def get_audio_template(
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -150,6 +158,8 @@ async def update_audio_template(
|
||||
engine_config=data.engine_config,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_template", "updated", template_id)
|
||||
return AudioTemplateResponse(
|
||||
@@ -161,6 +171,8 @@ async def update_audio_template(
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@@ -23,6 +23,7 @@ from ledgrab.storage.automation import (
|
||||
ApplicationRule,
|
||||
DisplayStateRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
MQTTRule,
|
||||
Rule,
|
||||
StartupRule,
|
||||
@@ -75,6 +76,11 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
|
||||
state=s.state or "",
|
||||
match_mode=s.match_mode or "exact",
|
||||
),
|
||||
"http_poll": lambda: HTTPPollRule(
|
||||
value_source_id=s.value_source_id or "",
|
||||
operator=s.operator or "equals",
|
||||
value=s.value or "",
|
||||
),
|
||||
}
|
||||
factory = _SCHEMA_TO_RULE.get(s.rule_type)
|
||||
if factory is None:
|
||||
@@ -122,6 +128,8 @@ def _automation_to_response(
|
||||
last_activated_at=state.get("last_activated_at"),
|
||||
last_deactivated_at=state.get("last_deactivated_at"),
|
||||
tags=automation.tags,
|
||||
icon=getattr(automation, "icon", "") or "",
|
||||
icon_color=getattr(automation, "icon_color", "") or "",
|
||||
created_at=automation.created_at,
|
||||
updated_at=automation.updated_at,
|
||||
)
|
||||
@@ -191,6 +199,8 @@ async def create_automation(
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
|
||||
if automation.enabled:
|
||||
@@ -285,6 +295,8 @@ async def update_automation(
|
||||
rules=rules,
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
if data.scene_preset_id is not None:
|
||||
update_kwargs["scene_preset_id"] = data.scene_preset_id
|
||||
|
||||
@@ -28,7 +28,7 @@ from ledgrab.config import get_config
|
||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.storage.database import Database, freeze_writes
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import get_logger, read_upload_capped
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -133,9 +133,11 @@ async def restore_config(
|
||||
because restore replaces all configuration including secrets).
|
||||
"""
|
||||
require_authenticated(auth)
|
||||
raw = await file.read()
|
||||
if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets)
|
||||
raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)")
|
||||
_MAX_BACKUP_BYTES = 200 * 1024 * 1024 # 200 MB (ZIP may contain assets)
|
||||
try:
|
||||
raw = await read_upload_capped(file, _MAX_BACKUP_BYTES)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=413, detail="Backup file too large (max 200 MB)")
|
||||
|
||||
if len(raw) < 100:
|
||||
raise HTTPException(status_code=400, detail="File too small to be a valid backup")
|
||||
|
||||
@@ -43,6 +43,8 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +86,8 @@ async def create_cspt(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("cspt", "created", template.id)
|
||||
return _cspt_to_response(template)
|
||||
@@ -141,6 +145,8 @@ async def update_cspt(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("cspt", "updated", template_id)
|
||||
return _cspt_to_response(template)
|
||||
|
||||
@@ -4,12 +4,12 @@ from ledgrab.api.schemas.color_strip_sources import (
|
||||
ApiInputCSSResponse,
|
||||
AudioCSSResponse,
|
||||
CandlelightCSSResponse,
|
||||
ColorCycleCSSResponse,
|
||||
ColorStop as ColorStopSchema,
|
||||
ColorStripSourceResponse,
|
||||
CompositeCSSResponse,
|
||||
DaylightCSSResponse,
|
||||
EffectCSSResponse,
|
||||
GameEventCSSResponse,
|
||||
GradientCSSResponse,
|
||||
KeyColorsCSSResponse,
|
||||
MappedCSSResponse,
|
||||
@@ -18,7 +18,7 @@ from ledgrab.api.schemas.color_strip_sources import (
|
||||
PictureAdvancedCSSResponse,
|
||||
PictureCSSResponse,
|
||||
ProcessedCSSResponse,
|
||||
StaticCSSResponse,
|
||||
SingleColorCSSResponse,
|
||||
WeatherCSSResponse,
|
||||
)
|
||||
from ledgrab.api.schemas.devices import Calibration as CalibrationSchema
|
||||
@@ -27,23 +27,7 @@ from ledgrab.core.capture.calibration import (
|
||||
calibration_to_dict,
|
||||
)
|
||||
from ledgrab.storage.color_strip_source import (
|
||||
AdvancedPictureColorStripSource,
|
||||
ApiInputColorStripSource,
|
||||
AudioColorStripSource,
|
||||
CandlelightColorStripSource,
|
||||
ColorCycleColorStripSource,
|
||||
CompositeColorStripSource,
|
||||
DaylightColorStripSource,
|
||||
EffectColorStripSource,
|
||||
GradientColorStripSource,
|
||||
KeyColorsColorStripSource,
|
||||
MappedColorStripSource,
|
||||
MathWaveColorStripSource,
|
||||
NotificationColorStripSource,
|
||||
PictureColorStripSource,
|
||||
ProcessedColorStripSource,
|
||||
StaticColorStripSource,
|
||||
WeatherColorStripSource,
|
||||
_SOURCE_TYPE_MAP as _STORAGE_TYPE_MAP,
|
||||
)
|
||||
from ledgrab.storage.picture_source import (
|
||||
ProcessedPictureSource,
|
||||
@@ -67,6 +51,8 @@ def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -94,38 +80,46 @@ def _stops_schema(source) -> list[ColorStopSchema] | None:
|
||||
return None
|
||||
|
||||
|
||||
# Maps storage class → response builder lambda.
|
||||
# Maps ``source_type`` string → response builder.
|
||||
#
|
||||
# Keying by source_type (rather than type(source)) lets the import-time
|
||||
# coverage check use the storage registry's keys directly, with no
|
||||
# inversion or duplicate-class handling for legacy aliases.
|
||||
_RESPONSE_MAP: dict = {
|
||||
PictureColorStripSource: lambda s, kw: PictureCSSResponse(
|
||||
"picture": lambda s, kw: PictureCSSResponse(
|
||||
**kw,
|
||||
picture_source_id=s.picture_source_id,
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
interpolation_mode=s.interpolation_mode,
|
||||
calibration=_calibration_schema(s),
|
||||
),
|
||||
AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse(
|
||||
"picture_advanced": lambda s, kw: PictureAdvancedCSSResponse(
|
||||
**kw,
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
interpolation_mode=s.interpolation_mode,
|
||||
calibration=_calibration_schema(s),
|
||||
),
|
||||
StaticColorStripSource: lambda s, kw: StaticCSSResponse(
|
||||
"single_color": lambda s, kw: SingleColorCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
animation=s.animation,
|
||||
),
|
||||
GradientColorStripSource: lambda s, kw: GradientCSSResponse(
|
||||
# Legacy alias: pre-rename rows used "static"; the data migration rewrites
|
||||
# them on first store load but a stale in-flight instance would still
|
||||
# carry source_type='static' until the next reload.
|
||||
"static": lambda s, kw: SingleColorCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
animation=s.animation,
|
||||
),
|
||||
"gradient": lambda s, kw: GradientCSSResponse(
|
||||
**kw,
|
||||
stops=_stops_schema(s),
|
||||
animation=s.animation,
|
||||
easing=s.easing,
|
||||
gradient_id=s.gradient_id,
|
||||
),
|
||||
ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse(
|
||||
**kw,
|
||||
colors=[list(c) for c in s.colors],
|
||||
),
|
||||
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
|
||||
"effect": lambda s, kw: EffectCSSResponse(
|
||||
**kw,
|
||||
effect_type=s.effect_type,
|
||||
palette=s.palette,
|
||||
@@ -136,15 +130,15 @@ _RESPONSE_MAP: dict = {
|
||||
mirror=s.mirror,
|
||||
custom_palette=s.custom_palette,
|
||||
),
|
||||
CompositeColorStripSource: lambda s, kw: CompositeCSSResponse(
|
||||
"composite": lambda s, kw: CompositeCSSResponse(
|
||||
**kw,
|
||||
layers=[dict(layer) for layer in s.layers],
|
||||
),
|
||||
MappedColorStripSource: lambda s, kw: MappedCSSResponse(
|
||||
"mapped": lambda s, kw: MappedCSSResponse(
|
||||
**kw,
|
||||
zones=[dict(z) for z in s.zones],
|
||||
),
|
||||
AudioColorStripSource: lambda s, kw: AudioCSSResponse(
|
||||
"audio": lambda s, kw: AudioCSSResponse(
|
||||
**kw,
|
||||
visualization_mode=s.visualization_mode,
|
||||
audio_source_id=s.audio_source_id,
|
||||
@@ -157,13 +151,13 @@ _RESPONSE_MAP: dict = {
|
||||
mirror=s.mirror,
|
||||
beat_decay=s.beat_decay.to_dict(),
|
||||
),
|
||||
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
|
||||
"api_input": lambda s, kw: ApiInputCSSResponse(
|
||||
**kw,
|
||||
fallback_color=s.fallback_color.to_dict(),
|
||||
timeout=s.timeout.to_dict(),
|
||||
interpolation=s.interpolation,
|
||||
),
|
||||
NotificationColorStripSource: lambda s, kw: NotificationCSSResponse(
|
||||
"notification": lambda s, kw: NotificationCSSResponse(
|
||||
**kw,
|
||||
notification_effect=s.notification_effect,
|
||||
duration_ms=s.duration_ms.to_dict(),
|
||||
@@ -176,14 +170,14 @@ _RESPONSE_MAP: dict = {
|
||||
sound_volume=s.sound_volume.to_dict(),
|
||||
app_sounds=dict(s.app_sounds),
|
||||
),
|
||||
DaylightColorStripSource: lambda s, kw: DaylightCSSResponse(
|
||||
"daylight": lambda s, kw: DaylightCSSResponse(
|
||||
**kw,
|
||||
speed=s.speed.to_dict(),
|
||||
use_real_time=s.use_real_time,
|
||||
latitude=s.latitude,
|
||||
longitude=s.longitude,
|
||||
),
|
||||
CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse(
|
||||
"candlelight": lambda s, kw: CandlelightCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
intensity=s.intensity.to_dict(),
|
||||
@@ -192,18 +186,18 @@ _RESPONSE_MAP: dict = {
|
||||
wind_strength=s.wind_strength.to_dict(),
|
||||
candle_type=s.candle_type,
|
||||
),
|
||||
ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse(
|
||||
"processed": lambda s, kw: ProcessedCSSResponse(
|
||||
**kw,
|
||||
input_source_id=s.input_source_id,
|
||||
processing_template_id=s.processing_template_id,
|
||||
),
|
||||
WeatherColorStripSource: lambda s, kw: WeatherCSSResponse(
|
||||
"weather": lambda s, kw: WeatherCSSResponse(
|
||||
**kw,
|
||||
weather_source_id=s.weather_source_id,
|
||||
speed=s.speed.to_dict(),
|
||||
temperature_influence=s.temperature_influence.to_dict(),
|
||||
),
|
||||
KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse(
|
||||
"key_colors": lambda s, kw: KeyColorsCSSResponse(
|
||||
**kw,
|
||||
picture_source_id=s.picture_source_id,
|
||||
rectangles=[r.to_dict() for r in s.rectangles],
|
||||
@@ -211,28 +205,67 @@ _RESPONSE_MAP: dict = {
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
brightness=s.brightness.to_dict(),
|
||||
),
|
||||
MathWaveColorStripSource: lambda s, kw: MathWaveCSSResponse(
|
||||
"math_wave": lambda s, kw: MathWaveCSSResponse(
|
||||
**kw,
|
||||
waves=s.waves,
|
||||
speed=s.speed.to_dict(),
|
||||
gradient_id=s.gradient_id,
|
||||
),
|
||||
"game_event": lambda s, kw: GameEventCSSResponse(
|
||||
**kw,
|
||||
game_integration_id=s.game_integration_id,
|
||||
idle_color=s.idle_color.to_dict(),
|
||||
event_mappings=[dict(m) for m in s.event_mappings],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _assert_response_map_coverage() -> None:
|
||||
"""Verify _RESPONSE_MAP has a builder for every kind in storage's registry.
|
||||
|
||||
Runs at module import. Surfaces missing builders eagerly instead of
|
||||
letting a request fall through to a silent / wrong response shape.
|
||||
|
||||
Contract note
|
||||
-------------
|
||||
This check is **symmetric** (``_RESPONSE_MAP keys == storage_kinds``)
|
||||
because every kind — sharable or not — needs a response shape. The
|
||||
sister assertion in
|
||||
``core/processing/color_strip_kinds.py::_assert_stream_kind_coverage``
|
||||
is asymmetric because sharable kinds construct their streams via a
|
||||
different path. Adding a new kind requires keeping all three registries
|
||||
aligned: storage's ``_SOURCE_TYPE_MAP``, this ``_RESPONSE_MAP``, and
|
||||
either ``STREAM_BUILDERS`` or ``SHARABLE_KINDS``.
|
||||
"""
|
||||
storage_kinds = set(_STORAGE_TYPE_MAP.keys())
|
||||
builder_kinds = set(_RESPONSE_MAP.keys())
|
||||
missing = storage_kinds - builder_kinds
|
||||
extra = builder_kinds - storage_kinds
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing builders for: {sorted(missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered kinds in _RESPONSE_MAP: {sorted(extra)}")
|
||||
raise RuntimeError(
|
||||
"_RESPONSE_MAP is out of sync with storage._SOURCE_TYPE_MAP: " + "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_response_map_coverage()
|
||||
|
||||
|
||||
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
|
||||
"""Convert a ColorStripSource to the matching per-type response schema."""
|
||||
kw = _common_response_kwargs(source, overlay_active)
|
||||
builder = _RESPONSE_MAP.get(type(source))
|
||||
builder = _RESPONSE_MAP.get(source.source_type)
|
||||
if builder is None:
|
||||
# Fallback: use to_dict() and build a PictureCSSResponse
|
||||
logger.warning("No response builder for %s, falling back", type(source).__name__)
|
||||
return PictureCSSResponse(
|
||||
**kw,
|
||||
picture_source_id="",
|
||||
smoothing=0.3,
|
||||
interpolation_mode="average",
|
||||
calibration=None,
|
||||
# Coverage is asserted at import time, so reaching this branch means a
|
||||
# source was loaded with a source_type that is not registered.
|
||||
# Surface the bug instead of silently returning a wrong-shaped response.
|
||||
raise RuntimeError(
|
||||
f"No CSS response builder registered for source_type "
|
||||
f"{source.source_type!r} (class={type(source).__name__})"
|
||||
)
|
||||
return builder(source, kw)
|
||||
|
||||
|
||||
@@ -29,13 +29,20 @@ router = APIRouter()
|
||||
|
||||
|
||||
_PREVIEW_ALLOWED_TYPES = {
|
||||
"static",
|
||||
"single_color",
|
||||
"gradient",
|
||||
"color_cycle",
|
||||
"effect",
|
||||
"daylight",
|
||||
"candlelight",
|
||||
"notification",
|
||||
"audio",
|
||||
"math_wave",
|
||||
"weather",
|
||||
"game_event",
|
||||
"api_input",
|
||||
"mapped",
|
||||
"composite",
|
||||
"processed",
|
||||
}
|
||||
|
||||
|
||||
@@ -90,13 +97,65 @@ async def preview_color_strip_ws(
|
||||
return ColorStripSource.from_dict(config)
|
||||
|
||||
def _create_stream(source):
|
||||
"""Instantiate and start the appropriate stream class for *source*."""
|
||||
from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
|
||||
"""Instantiate and start the appropriate stream class for *source*.
|
||||
|
||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||
if not stream_cls:
|
||||
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
|
||||
s = stream_cls(source)
|
||||
Delegates the per-kind dispatch to ``color_strip_kinds.build_stream``
|
||||
so this preview path and the production ``ColorStripStreamManager``
|
||||
share a single registry. Per-kind dependencies (CSPT store, audio
|
||||
stores, weather manager, …) are gathered into a ``StreamDeps`` bag.
|
||||
|
||||
FastAPI-DI providers raise ``RuntimeError`` when they aren't wired,
|
||||
so we resolve each one through ``_safe`` and pass ``None`` on
|
||||
failure. The per-kind builder will still see a clear error if a
|
||||
truly-required dep is missing for that kind, but unrelated previews
|
||||
(e.g. a ``single_color`` preview on a fresh install where the CSPT
|
||||
store isn't initialized yet) keep working.
|
||||
"""
|
||||
from ledgrab.api.dependencies import (
|
||||
get_audio_processing_template_store,
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
get_cspt_store,
|
||||
)
|
||||
from ledgrab.core.processing.color_strip_kinds import StreamDeps, build_stream
|
||||
|
||||
def _safe(getter):
|
||||
try:
|
||||
return getter()
|
||||
except RuntimeError as e:
|
||||
logger.debug("Preview dep not available (%s): %s", getter.__name__, e)
|
||||
return None
|
||||
|
||||
mgr = get_processor_manager()
|
||||
csm = mgr.color_strip_stream_manager
|
||||
|
||||
# The game-event bus is optional in preview contexts.
|
||||
try:
|
||||
from ledgrab.api.dependencies import get_game_event_bus
|
||||
|
||||
game_event_bus = get_game_event_bus()
|
||||
except RuntimeError as e:
|
||||
logger.debug("Preview: no game event bus available: %s", e)
|
||||
game_event_bus = None
|
||||
|
||||
deps = StreamDeps(
|
||||
css_manager=csm,
|
||||
value_stream_manager=mgr.value_stream_manager,
|
||||
cspt_store=_safe(get_cspt_store),
|
||||
weather_manager=mgr.weather_manager,
|
||||
audio_capture_manager=mgr.audio_capture_manager,
|
||||
audio_source_store=_safe(get_audio_source_store),
|
||||
audio_template_store=_safe(get_audio_template_store),
|
||||
audio_processing_template_store=_safe(get_audio_processing_template_store),
|
||||
game_event_bus=game_event_bus,
|
||||
depth=0,
|
||||
)
|
||||
try:
|
||||
s = build_stream(source, deps)
|
||||
except ValueError as e:
|
||||
# Preserve the registry's original detail so the API consumer
|
||||
# sees which kind was rejected, not just a generic message.
|
||||
raise ValueError(f"Unsupported preview source_type: {e}") from e
|
||||
# Inject gradient store for palette resolution
|
||||
if hasattr(s, "set_gradient_store"):
|
||||
try:
|
||||
@@ -122,7 +181,24 @@ async def preview_color_strip_ws(
|
||||
cid = None
|
||||
else:
|
||||
cid = None
|
||||
s.start()
|
||||
# Start the stream; if start() raises, release any resources we
|
||||
# already acquired (clock + anything the stream itself grabbed in
|
||||
# its __init__) so we don't leak refs across failed previews.
|
||||
try:
|
||||
s.start()
|
||||
except Exception:
|
||||
try:
|
||||
s.stop()
|
||||
except Exception as e_stop:
|
||||
logger.exception("unexpected in start-failure rollback s.stop: %s", e_stop)
|
||||
if cid:
|
||||
scm = _get_sync_clock_manager()
|
||||
if scm:
|
||||
try:
|
||||
scm.release(cid)
|
||||
except Exception as e_rel:
|
||||
logger.exception("unexpected in start-failure clock release: %s", e_rel)
|
||||
raise
|
||||
return s, cid
|
||||
|
||||
def _stop_stream(s, cid):
|
||||
@@ -223,10 +299,24 @@ async def preview_color_strip_ws(
|
||||
continue
|
||||
new_source = _build_source(new_config)
|
||||
if new_type != current_source_type:
|
||||
# Source type changed — recreate stream
|
||||
# Source type changed — stop the old stream first, then
|
||||
# build the new one. If the rebuild fails, drop the
|
||||
# reference so the frame loop doesn't keep polling a
|
||||
# stopped stream and the finally-block doesn't double-stop.
|
||||
_stop_stream(stream, clock_id)
|
||||
stream, clock_id = _create_stream(new_source)
|
||||
current_source_type = new_type
|
||||
stream, clock_id = None, None
|
||||
try:
|
||||
stream, clock_id = _create_stream(new_source)
|
||||
current_source_type = new_type
|
||||
except Exception as rebuild_err:
|
||||
logger.error(
|
||||
f"Preview WS: failed to rebuild stream for new type {new_type}: {rebuild_err}"
|
||||
)
|
||||
await websocket.send_text(
|
||||
_json.dumps({"type": "error", "detail": str(rebuild_err)})
|
||||
)
|
||||
await websocket.close(code=4003, reason=str(rebuild_err))
|
||||
return
|
||||
else:
|
||||
stream.update_source(new_source)
|
||||
if hasattr(stream, "configure"):
|
||||
@@ -237,12 +327,15 @@ async def preview_color_strip_ws(
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
|
||||
|
||||
# Send frame
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
else:
|
||||
# Stream hasn't produced a frame yet — send black
|
||||
if stream is None:
|
||||
await websocket.send_bytes(b"\x00" * led_count * 3)
|
||||
else:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
else:
|
||||
# Stream hasn't produced a frame yet — send black
|
||||
await websocket.send_bytes(b"\x00" * led_count * 3)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
@@ -335,8 +428,17 @@ async def css_api_input_ws(
|
||||
continue
|
||||
|
||||
elif "bytes" in message:
|
||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
|
||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED).
|
||||
# Cap to a generous upper bound on the LED count — a hostile
|
||||
# client could otherwise stream 100 MB frames and OOM the
|
||||
# server before any application logic ran.
|
||||
raw_bytes = message["bytes"]
|
||||
_MAX_BINARY_LEDS = 8192
|
||||
if len(raw_bytes) > _MAX_BINARY_LEDS * 3:
|
||||
await websocket.send_json(
|
||||
{"error": f"Binary frame too large (max {_MAX_BINARY_LEDS} LEDs)"}
|
||||
)
|
||||
continue
|
||||
if len(raw_bytes) % 3 != 0:
|
||||
await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"})
|
||||
continue
|
||||
@@ -476,13 +578,16 @@ async def test_color_strip_ws(
|
||||
meta["layer_infos"] = layer_infos
|
||||
await websocket.send_text(_json.dumps(meta))
|
||||
|
||||
# For api_input: send the current buffer immediately so the client
|
||||
# gets a frame right away (fallback color if inactive) rather than
|
||||
# leaving the canvas blank/stale until external data arrives.
|
||||
# For api_input: only send an initial frame if a client has actually
|
||||
# pushed data (push_generation > 0). Without prior data, the preview
|
||||
# stays blank instead of showing the fallback buffer as a stray frame.
|
||||
if is_api_input:
|
||||
initial_colors = stream.get_latest_colors()
|
||||
if initial_colors is not None:
|
||||
await websocket.send_bytes(initial_colors.tobytes())
|
||||
initial_gen = stream.push_generation
|
||||
if initial_gen > 0:
|
||||
_last_push_gen = initial_gen
|
||||
initial_colors = stream.get_latest_colors()
|
||||
if initial_colors is not None:
|
||||
await websocket.send_bytes(initial_colors.tobytes())
|
||||
|
||||
# For picture sources, grab the live stream for frame preview
|
||||
_frame_live = None
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.core.devices.led_client import (
|
||||
PairingNotReady,
|
||||
get_all_providers,
|
||||
get_device_capabilities,
|
||||
get_provider,
|
||||
@@ -26,18 +27,45 @@ from ledgrab.api.schemas.devices import (
|
||||
DiscoverDevicesResponse,
|
||||
OpenRGBZoneResponse,
|
||||
OpenRGBZonesResponse,
|
||||
PairDeviceRequest,
|
||||
PairDeviceResponse,
|
||||
PowerRequest,
|
||||
)
|
||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||
from ledgrab.storage import DeviceStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.url_scheme import infer_http_scheme
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _sanitize_url_for_log(url: str) -> str:
|
||||
"""Strip userinfo + fragment from a URL so secrets don't reach logs.
|
||||
|
||||
The pair endpoint receives a user-supplied URL on every call; if a
|
||||
future driver ever accepts ``scheme://user:pass@host`` form the
|
||||
credentials would land in logs without this guard.
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
try:
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
# urlparse stores userinfo in `netloc`; rebuild without it.
|
||||
if parsed.hostname:
|
||||
netloc = parsed.hostname
|
||||
if parsed.port:
|
||||
netloc = f"{netloc}:{parsed.port}"
|
||||
return urlunparse((parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, ""))
|
||||
except ValueError:
|
||||
pass
|
||||
return url
|
||||
|
||||
|
||||
def _device_to_response(device) -> DeviceResponse:
|
||||
"""Convert a Device to DeviceResponse."""
|
||||
return DeviceResponse(
|
||||
@@ -57,11 +85,20 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
dmx_protocol=device.dmx_protocol,
|
||||
dmx_start_universe=device.dmx_start_universe,
|
||||
dmx_start_channel=device.dmx_start_channel,
|
||||
ddp_port=device.ddp_port,
|
||||
ddp_destination_id=device.ddp_destination_id,
|
||||
ddp_color_order=device.ddp_color_order,
|
||||
espnow_peer_mac=device.espnow_peer_mac,
|
||||
espnow_channel=device.espnow_channel,
|
||||
hue_username=device.hue_username,
|
||||
hue_client_key=device.hue_client_key,
|
||||
hue_paired=bool(device.hue_username and device.hue_client_key),
|
||||
hue_entertainment_group_id=device.hue_entertainment_group_id,
|
||||
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=device.wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=device.lifx_min_interval_ms,
|
||||
govee_min_interval_ms=device.govee_min_interval_ms,
|
||||
opc_channel=device.opc_channel,
|
||||
nanoleaf_paired=bool(device.nanoleaf_token),
|
||||
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
|
||||
spi_speed_hz=device.spi_speed_hz,
|
||||
spi_led_type=device.spi_led_type,
|
||||
chroma_device_type=device.chroma_device_type,
|
||||
@@ -71,6 +108,8 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
default_css_processing_template_id=device.default_css_processing_template_id,
|
||||
group_device_ids=device.group_device_ids,
|
||||
group_mode=device.group_mode,
|
||||
icon=getattr(device, "icon", "") or "",
|
||||
icon_color=getattr(device, "icon_color", "") or "",
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
@@ -132,6 +171,8 @@ async def create_device(
|
||||
detail="URL is required for non-group device types.",
|
||||
)
|
||||
device_url = device_data.url.rstrip("/")
|
||||
if device_type == "wled":
|
||||
device_url = infer_http_scheme(device_url)
|
||||
|
||||
# ── Non-group: validate via provider ──
|
||||
if device_type != "group":
|
||||
@@ -166,9 +207,19 @@ async def create_device(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Don't leak the raw exception text — it can carry stack
|
||||
# frames, host headers, or other internals that aren't safe
|
||||
# to echo. Log with full context, return a generic message.
|
||||
logger.warning(
|
||||
"Failed to validate %s device at %s: %s",
|
||||
device_type,
|
||||
device_url,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Failed to connect to {device_type} device at {device_url}: {e}",
|
||||
detail=f"Failed to connect to {device_type} device at {device_url}.",
|
||||
)
|
||||
|
||||
# Resolve auto_shutdown default: False for all types
|
||||
@@ -179,7 +230,7 @@ async def create_device(
|
||||
# Create device in storage
|
||||
device = store.create_device(
|
||||
name=device_data.name,
|
||||
url=device_data.url,
|
||||
url=device_url,
|
||||
led_count=led_count,
|
||||
device_type=device_type,
|
||||
baud_rate=device_data.baud_rate,
|
||||
@@ -191,11 +242,45 @@ async def create_device(
|
||||
dmx_protocol=device_data.dmx_protocol or "artnet",
|
||||
dmx_start_universe=device_data.dmx_start_universe or 0,
|
||||
dmx_start_channel=device_data.dmx_start_channel or 1,
|
||||
ddp_port=device_data.ddp_port or 0,
|
||||
ddp_destination_id=(
|
||||
device_data.ddp_destination_id if device_data.ddp_destination_id is not None else 1
|
||||
),
|
||||
ddp_color_order=(
|
||||
device_data.ddp_color_order if device_data.ddp_color_order is not None else 1
|
||||
),
|
||||
espnow_peer_mac=device_data.espnow_peer_mac or "",
|
||||
espnow_channel=device_data.espnow_channel or 1,
|
||||
hue_username=device_data.hue_username or "",
|
||||
hue_client_key=device_data.hue_client_key or "",
|
||||
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
|
||||
yeelight_min_interval_ms=(
|
||||
device_data.yeelight_min_interval_ms
|
||||
if device_data.yeelight_min_interval_ms is not None
|
||||
else 500
|
||||
),
|
||||
wiz_min_interval_ms=(
|
||||
device_data.wiz_min_interval_ms
|
||||
if device_data.wiz_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
lifx_min_interval_ms=(
|
||||
device_data.lifx_min_interval_ms
|
||||
if device_data.lifx_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
govee_min_interval_ms=(
|
||||
device_data.govee_min_interval_ms
|
||||
if device_data.govee_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
opc_channel=(device_data.opc_channel if device_data.opc_channel is not None else 0),
|
||||
nanoleaf_token=device_data.nanoleaf_token or "",
|
||||
nanoleaf_min_interval_ms=(
|
||||
device_data.nanoleaf_min_interval_ms
|
||||
if device_data.nanoleaf_min_interval_ms is not None
|
||||
else 100
|
||||
),
|
||||
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
||||
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||
@@ -231,6 +316,79 @@ async def create_device(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/devices/pair",
|
||||
response_model=PairDeviceResponse,
|
||||
tags=["Devices"],
|
||||
)
|
||||
async def pair_device(
|
||||
body: PairDeviceRequest,
|
||||
_auth: AuthRequired,
|
||||
):
|
||||
"""Run a pairing handshake against a device before creating it.
|
||||
|
||||
The frontend opens this endpoint after the user has performed the
|
||||
device's physical pairing action (e.g. held the power button for 5s).
|
||||
The response carries provider-specific fields the caller must include
|
||||
in the subsequent ``POST /api/v1/devices`` body.
|
||||
|
||||
Status codes:
|
||||
200 paired — fields returned
|
||||
400 unknown device type, or device type does not support pairing
|
||||
409 device not ready — user must perform the physical action
|
||||
(or retry, e.g. the pairing window timed out)
|
||||
422 invalid URL or device configuration
|
||||
"""
|
||||
try:
|
||||
provider = get_provider(body.device_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown device type: {body.device_type}")
|
||||
|
||||
try:
|
||||
fields = await provider.pair_device(body.url)
|
||||
except NotImplementedError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device type {body.device_type!r} does not support pairing",
|
||||
)
|
||||
except PairingNotReady as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except Exception as exc:
|
||||
# Strip userinfo before logging so a `scheme://user:pass@host` URL
|
||||
# never lands in the logs (no shipped driver uses userinfo today,
|
||||
# but the pattern is a foot-gun for the next driver author --
|
||||
# caught by review MEDIUM #9). Also keep exc_info=False so a
|
||||
# provider stack trace that may include response bytes from a
|
||||
# hostile receiver doesn't end up in the file either.
|
||||
safe_url = _sanitize_url_for_log(body.url)
|
||||
logger.warning(
|
||||
"Pairing failed for %s at %s: %s: %s",
|
||||
body.device_type,
|
||||
safe_url,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Pairing failed for {body.device_type} at {safe_url}.",
|
||||
)
|
||||
|
||||
if not isinstance(fields, dict):
|
||||
logger.warning(
|
||||
"Provider %s.pair_device returned %r (expected dict)",
|
||||
body.device_type,
|
||||
type(fields).__name__,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Provider {body.device_type!r} returned malformed pairing result",
|
||||
)
|
||||
|
||||
return PairDeviceResponse(fields=fields)
|
||||
|
||||
|
||||
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
|
||||
async def list_devices(
|
||||
_auth: AuthRequired,
|
||||
@@ -264,11 +422,20 @@ async def discover_devices(
|
||||
raise HTTPException(status_code=400, detail=f"Unknown device type: {device_type}")
|
||||
discovered = await provider.discover(timeout=capped_timeout)
|
||||
else:
|
||||
# Discover from all providers in parallel
|
||||
# Discover from all providers in parallel. Discovery is best-effort:
|
||||
# one provider failing (firewall, missing dep, mDNS race) must not
|
||||
# take the entire scan down, so collect exceptions instead of
|
||||
# raising and log them individually.
|
||||
providers = get_all_providers()
|
||||
discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()]
|
||||
all_results = await asyncio.gather(*discover_tasks)
|
||||
discovered = [d for batch in all_results for d in batch]
|
||||
provider_items = list(providers.items())
|
||||
discover_tasks = [p.discover(timeout=capped_timeout) for _, p in provider_items]
|
||||
all_results = await asyncio.gather(*discover_tasks, return_exceptions=True)
|
||||
discovered = []
|
||||
for (name, _), result in zip(provider_items, all_results):
|
||||
if isinstance(result, BaseException):
|
||||
logger.warning("Discovery failed for provider %s: %s", name, result)
|
||||
continue
|
||||
discovered.extend(result)
|
||||
elapsed_ms = (time.time() - start) * 1000
|
||||
|
||||
existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()}
|
||||
@@ -383,6 +550,26 @@ async def update_device(
|
||||
existing = store.get_device(device_id)
|
||||
is_group = existing.device_type == "group"
|
||||
|
||||
# Normalize URL the same way we do on create:
|
||||
# * always rstrip trailing slashes (so PUT-with-trailing-/ matches
|
||||
# POST-with-trailing-/ in the stored value -- caught by review HIGH #6)
|
||||
# * only WLED gets http/https scheme inference; other schemes
|
||||
# (yeelight://, lifx://, opc://, ddp://, …) pass through.
|
||||
# Done via a local rather than mutating the request DTO so the
|
||||
# input is preserved for any future caller that inspects it.
|
||||
normalized_url = update_data.url
|
||||
if update_data.url:
|
||||
normalized_url = update_data.url.rstrip("/")
|
||||
if existing.device_type == "wled":
|
||||
inferred = infer_http_scheme(normalized_url)
|
||||
if inferred != normalized_url:
|
||||
logger.debug("Inferred WLED URL scheme: %r -> %r", normalized_url, inferred)
|
||||
normalized_url = inferred
|
||||
|
||||
# Group-only field overrides (led_count auto-recompute) are accumulated
|
||||
# here too so the update_data Pydantic model is not mutated in place.
|
||||
normalized_led_count = update_data.led_count
|
||||
|
||||
if is_group:
|
||||
new_children = update_data.group_device_ids
|
||||
new_mode = update_data.group_mode or existing.group_mode
|
||||
@@ -403,20 +590,20 @@ async def update_device(
|
||||
|
||||
# Auto-recompute led_count for sequence mode
|
||||
if effective_mode == "sequence":
|
||||
update_data.led_count = store.resolve_group_led_count(effective_children)
|
||||
normalized_led_count = store.resolve_group_led_count(effective_children)
|
||||
elif (
|
||||
update_data.led_count is None
|
||||
normalized_led_count is None
|
||||
and new_mode == "independent"
|
||||
and new_children is not None
|
||||
):
|
||||
update_data.led_count = store.resolve_group_max_led_count(effective_children)
|
||||
normalized_led_count = store.resolve_group_max_led_count(effective_children)
|
||||
|
||||
device = store.update_device(
|
||||
device_id=device_id,
|
||||
name=update_data.name,
|
||||
url=update_data.url,
|
||||
url=normalized_url,
|
||||
enabled=update_data.enabled,
|
||||
led_count=update_data.led_count,
|
||||
led_count=normalized_led_count,
|
||||
baud_rate=update_data.baud_rate,
|
||||
auto_shutdown=update_data.auto_shutdown,
|
||||
send_latency_ms=update_data.send_latency_ms,
|
||||
@@ -426,11 +613,21 @@ async def update_device(
|
||||
dmx_protocol=update_data.dmx_protocol,
|
||||
dmx_start_universe=update_data.dmx_start_universe,
|
||||
dmx_start_channel=update_data.dmx_start_channel,
|
||||
ddp_port=update_data.ddp_port,
|
||||
ddp_destination_id=update_data.ddp_destination_id,
|
||||
ddp_color_order=update_data.ddp_color_order,
|
||||
espnow_peer_mac=update_data.espnow_peer_mac,
|
||||
espnow_channel=update_data.espnow_channel,
|
||||
hue_username=update_data.hue_username,
|
||||
hue_client_key=update_data.hue_client_key,
|
||||
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
||||
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
|
||||
govee_min_interval_ms=update_data.govee_min_interval_ms,
|
||||
opc_channel=update_data.opc_channel,
|
||||
nanoleaf_token=update_data.nanoleaf_token,
|
||||
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
|
||||
spi_speed_hz=update_data.spi_speed_hz,
|
||||
spi_led_type=update_data.spi_led_type,
|
||||
chroma_device_type=update_data.chroma_device_type,
|
||||
@@ -439,19 +636,27 @@ async def update_device(
|
||||
ble_govee_key=update_data.ble_govee_key,
|
||||
group_device_ids=update_data.group_device_ids,
|
||||
group_mode=update_data.group_mode,
|
||||
icon=update_data.icon,
|
||||
icon_color=update_data.icon_color,
|
||||
)
|
||||
|
||||
# Sync connection info in processor manager
|
||||
# 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=update_data.url,
|
||||
led_count=update_data.led_count,
|
||||
device_url=effective_url,
|
||||
led_count=normalized_led_count,
|
||||
baud_rate=update_data.baud_rate,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.debug("Processor manager device update skipped for %s: %s", device_id, e)
|
||||
pass
|
||||
|
||||
# Sync auto_shutdown and zone_mode in runtime state
|
||||
ds = manager.find_device_state(device_id)
|
||||
|
||||
@@ -158,6 +158,8 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
|
||||
updated_at=config.updated_at,
|
||||
description=config.description,
|
||||
tags=config.tags,
|
||||
icon=getattr(config, "icon", "") or "",
|
||||
icon_color=getattr(config, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -255,6 +257,8 @@ async def create_integration(
|
||||
event_mappings=mappings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
|
||||
fire_entity_event("game_integration", "created", config.id)
|
||||
@@ -323,6 +327,8 @@ async def update_integration(
|
||||
event_mappings=mappings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
|
||||
fire_entity_event("game_integration", "updated", integration_id)
|
||||
|
||||
@@ -35,6 +35,8 @@ def _to_response(gradient: Gradient) -> GradientResponse:
|
||||
tags=gradient.tags,
|
||||
created_at=gradient.created_at,
|
||||
updated_at=gradient.updated_at,
|
||||
icon=getattr(gradient, "icon", "") or "",
|
||||
icon_color=getattr(gradient, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +68,8 @@ async def create_gradient(
|
||||
stops=[s.model_dump() for s in data.stops],
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("gradient", "created", gradient.id)
|
||||
return _to_response(gradient)
|
||||
@@ -103,6 +107,8 @@ async def update_gradient(
|
||||
stops=stops,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("gradient", "updated", gradient_id)
|
||||
return _to_response(gradient)
|
||||
|
||||
@@ -55,6 +55,8 @@ def _to_response(
|
||||
entity_count=len(runtime.get_all_states()) if runtime else 0,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
token=token_field,
|
||||
@@ -105,6 +107,8 @@ async def create_ha_source(
|
||||
entity_filters=data.entity_filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -158,6 +162,8 @@ async def update_ha_source(
|
||||
entity_filters=data.entity_filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||
@@ -316,6 +322,7 @@ async def get_ha_status(
|
||||
name=source.name,
|
||||
connected=connected,
|
||||
entity_count=status["entity_count"] if status else 0,
|
||||
host=source.host or "",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""HTTP endpoint routes: CRUD + one-shot test."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_http_endpoint_store,
|
||||
)
|
||||
from ledgrab.api.schemas.http_endpoints import (
|
||||
HTTPEndpointCreate,
|
||||
HTTPEndpointListResponse,
|
||||
HTTPEndpointResponse,
|
||||
HTTPEndpointUpdate,
|
||||
HTTPTestRequest,
|
||||
HTTPTestResponse,
|
||||
)
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.storage.http_endpoint import HTTPEndpoint
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.safe_source import safe_request_bounded, validate_polling_url
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _warn_if_plaintext_token(url: str, auth_token: str, *, action: str) -> None:
|
||||
"""Log a warning when an auth token would be sent over plaintext http://."""
|
||||
if auth_token and url.lower().startswith("http://"):
|
||||
logger.warning(
|
||||
"HTTP endpoint %s: auth_token will be sent over plaintext http:// to %s. "
|
||||
"Anyone on the network path can read it. Consider https:// if the "
|
||||
"target supports TLS.",
|
||||
action,
|
||||
url,
|
||||
)
|
||||
|
||||
|
||||
def _to_response(endpoint: HTTPEndpoint) -> HTTPEndpointResponse:
|
||||
return HTTPEndpointResponse(
|
||||
id=endpoint.id,
|
||||
name=endpoint.name,
|
||||
url=endpoint.url,
|
||||
method=endpoint.method,
|
||||
auth_token_set=bool(endpoint.auth_token),
|
||||
headers=dict(endpoint.headers),
|
||||
timeout_s=endpoint.timeout_s,
|
||||
description=endpoint.description,
|
||||
tags=endpoint.tags,
|
||||
icon=getattr(endpoint, "icon", "") or "",
|
||||
icon_color=getattr(endpoint, "icon_color", "") or "",
|
||||
created_at=endpoint.created_at,
|
||||
updated_at=endpoint.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/http/endpoints",
|
||||
response_model=HTTPEndpointListResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def list_http_endpoints(
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
endpoints = store.get_all_endpoints()
|
||||
return HTTPEndpointListResponse(
|
||||
endpoints=[_to_response(e) for e in endpoints],
|
||||
count=len(endpoints),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints",
|
||||
response_model=HTTPEndpointResponse,
|
||||
status_code=201,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def create_http_endpoint(
|
||||
data: HTTPEndpointCreate,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
validate_polling_url(data.url)
|
||||
_warn_if_plaintext_token(data.url, data.auth_token, action="create")
|
||||
try:
|
||||
endpoint = store.create_endpoint(
|
||||
name=data.name,
|
||||
url=data.url,
|
||||
method=data.method,
|
||||
auth_token=data.auth_token,
|
||||
headers=data.headers,
|
||||
timeout_s=data.timeout_s,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
fire_entity_event("http_endpoint", "created", endpoint.id)
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
response_model=HTTPEndpointResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def get_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
try:
|
||||
endpoint = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
response_model=HTTPEndpointResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def update_http_endpoint(
|
||||
endpoint_id: str,
|
||||
data: HTTPEndpointUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
if data.url is not None:
|
||||
validate_polling_url(data.url)
|
||||
final_url = data.url
|
||||
final_token = data.auth_token
|
||||
if final_url is None or final_token is None:
|
||||
try:
|
||||
existing = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
if final_url is None:
|
||||
final_url = existing.url
|
||||
if final_token is None:
|
||||
final_token = existing.auth_token
|
||||
_warn_if_plaintext_token(final_url, final_token, action="update")
|
||||
try:
|
||||
endpoint = store.update_endpoint(
|
||||
endpoint_id,
|
||||
name=data.name,
|
||||
url=data.url,
|
||||
method=data.method,
|
||||
auth_token=data.auth_token,
|
||||
headers=data.headers,
|
||||
timeout_s=data.timeout_s,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
fire_entity_event("http_endpoint", "updated", endpoint.id)
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
status_code=204,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def delete_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
try:
|
||||
store.delete_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
fire_entity_event("http_endpoint", "deleted", endpoint_id)
|
||||
|
||||
|
||||
async def _run_http_test(
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
timeout_s: float,
|
||||
) -> HTTPTestResponse:
|
||||
"""Shared one-shot fetch + response shaping for both test endpoints."""
|
||||
try:
|
||||
status, body_bytes, error = await safe_request_bounded(
|
||||
method, url, headers=headers, timeout=timeout_s
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
return HTTPTestResponse(success=False, error=f"Unexpected error: {type(exc).__name__}")
|
||||
|
||||
if error and status == 0:
|
||||
return HTTPTestResponse(success=False, error=error)
|
||||
|
||||
try:
|
||||
body_text = body_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
body_text = body_bytes.decode("utf-8", errors="replace")
|
||||
try:
|
||||
body_json = json.loads(body_text) if body_text else None
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
body_json = None
|
||||
|
||||
preview = body_text[:500] if body_text else None
|
||||
is_success = 200 <= status < 300
|
||||
return HTTPTestResponse(
|
||||
success=is_success,
|
||||
status_code=status,
|
||||
body_preview=preview,
|
||||
body_json=body_json,
|
||||
error=None if is_success else f"HTTP {status}",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints/test",
|
||||
response_model=HTTPTestResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def test_http_endpoint(
|
||||
data: HTTPTestRequest,
|
||||
_auth: AuthRequired,
|
||||
):
|
||||
"""One-shot fetch to validate URL + auth before saving."""
|
||||
headers = dict(data.headers)
|
||||
if data.auth_token and not any(k.lower() == "authorization" for k in headers):
|
||||
headers["Authorization"] = f"Bearer {data.auth_token}"
|
||||
return await _run_http_test(data.method, data.url, headers, data.timeout_s)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints/{endpoint_id}/test",
|
||||
response_model=HTTPTestResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def test_saved_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
"""Run the stored endpoint configuration (URL + auth + headers + timeout).
|
||||
|
||||
Useful for the "test" button on the endpoint card: avoids the user
|
||||
having to open the editor and re-enter the auth token (which is
|
||||
never returned to the client).
|
||||
"""
|
||||
try:
|
||||
endpoint = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
return await _run_http_test(
|
||||
endpoint.method,
|
||||
endpoint.url,
|
||||
endpoint.build_request_headers(),
|
||||
endpoint.timeout_s,
|
||||
)
|
||||
@@ -45,6 +45,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
|
||||
connected=runtime.is_connected if runtime else False,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -90,6 +92,8 @@ async def create_mqtt_source(
|
||||
base_topic=data.base_topic,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -139,6 +143,8 @@ async def update_mqtt_source(
|
||||
base_topic=data.base_topic,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found")
|
||||
|
||||
@@ -9,17 +9,27 @@ from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_device_store,
|
||||
get_mqtt_store,
|
||||
get_output_target_store,
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from ledgrab.api.schemas.output_targets import (
|
||||
HALightMappingSchema,
|
||||
HALightOutputTargetCreate,
|
||||
HALightOutputTargetResponse,
|
||||
HALightOutputTargetUpdate,
|
||||
LedOutputTargetCreate,
|
||||
LedOutputTargetResponse,
|
||||
LedOutputTargetUpdate,
|
||||
OutputTargetCreate,
|
||||
OutputTargetListResponse,
|
||||
OutputTargetResponse,
|
||||
OutputTargetUpdate,
|
||||
Z2MLightMappingSchema,
|
||||
Z2MLightOutputTargetCreate,
|
||||
Z2MLightOutputTargetResponse,
|
||||
Z2MLightOutputTargetUpdate,
|
||||
)
|
||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||
from ledgrab.storage import DeviceStore
|
||||
@@ -29,7 +39,13 @@ from ledgrab.storage.ha_light_output_target import (
|
||||
HALightMapping,
|
||||
HALightOutputTarget,
|
||||
)
|
||||
from ledgrab.storage.z2m_light_output_target import (
|
||||
Z2MLightMapping,
|
||||
Z2MLightOutputTarget,
|
||||
)
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
|
||||
@@ -54,6 +70,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
|
||||
protocol=target.protocol,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
icon=getattr(target, "icon", "") or "",
|
||||
icon_color=getattr(target, "icon_color", "") or "",
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
@@ -66,8 +84,11 @@ def _ha_light_target_to_response(
|
||||
return HALightOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
ha_source_id=target.ha_source_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
ha_source_id=target.ha_source_id or "",
|
||||
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
|
||||
# Defensive coalesce — older records stored via resolve_ref may hold None.
|
||||
color_strip_source_id=target.color_strip_source_id or "",
|
||||
color_value_source_id=target.color_value_source_id or "",
|
||||
brightness=target.brightness.to_dict(),
|
||||
ha_light_mappings=[
|
||||
HALightMappingSchema(
|
||||
@@ -82,34 +103,183 @@ def _ha_light_target_to_response(
|
||||
transition=target.transition.to_dict(),
|
||||
color_tolerance=target.color_tolerance.to_dict(),
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
stop_action=target.stop_action,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
icon=getattr(target, "icon", "") or "",
|
||||
icon_color=getattr(target, "icon_color", "") or "",
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _target_to_response(target) -> OutputTargetResponse:
|
||||
"""Convert any OutputTarget to the appropriate typed response."""
|
||||
if isinstance(target, WledOutputTarget):
|
||||
return _led_target_to_response(target)
|
||||
elif isinstance(target, HALightOutputTarget):
|
||||
return _ha_light_target_to_response(target)
|
||||
else:
|
||||
# Fallback for unknown types — use LED response with defaults
|
||||
return LedOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
def _z2m_light_target_to_response(
|
||||
target: Z2MLightOutputTarget,
|
||||
) -> Z2MLightOutputTargetResponse:
|
||||
"""Convert a Z2MLightOutputTarget to Z2MLightOutputTargetResponse."""
|
||||
return Z2MLightOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
mqtt_source_id=target.mqtt_source_id or "",
|
||||
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
|
||||
color_strip_source_id=target.color_strip_source_id or "",
|
||||
color_value_source_id=target.color_value_source_id or "",
|
||||
brightness=target.brightness.to_dict(),
|
||||
z2m_light_mappings=[
|
||||
Z2MLightMappingSchema(
|
||||
friendly_name=m.friendly_name,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=m.brightness_scale.to_dict(),
|
||||
)
|
||||
for m in target.light_mappings
|
||||
],
|
||||
base_topic=target.base_topic,
|
||||
update_rate=target.update_rate.to_dict(),
|
||||
transition=target.transition.to_dict(),
|
||||
color_tolerance=target.color_tolerance.to_dict(),
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
stop_action=target.stop_action if target.stop_action in ("none", "turn_off") else "none",
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
icon=getattr(target, "icon", "") or "",
|
||||
icon_color=getattr(target, "icon_color", "") or "",
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _validate_color_value_source(
|
||||
value_source_store: ValueSourceStore, color_value_source_id: str
|
||||
) -> None:
|
||||
"""Ensure the referenced ValueSource exists and returns colour."""
|
||||
if not color_value_source_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="color_value_source_id is required when source_kind='color_vs'",
|
||||
)
|
||||
try:
|
||||
source = value_source_store.get_source(color_value_source_id)
|
||||
except (ValueError, EntityNotFoundError):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Color value source {color_value_source_id} not found",
|
||||
)
|
||||
if source.to_dict().get("return_type") != "color":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Value source {color_value_source_id} does not return colour "
|
||||
"(return_type must be 'color')"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_TARGET_RESPONSE_BUILDERS: dict = {
|
||||
WledOutputTarget: _led_target_to_response,
|
||||
HALightOutputTarget: _ha_light_target_to_response,
|
||||
Z2MLightOutputTarget: _z2m_light_target_to_response,
|
||||
}
|
||||
|
||||
|
||||
def _assert_target_response_coverage() -> None:
|
||||
"""Verify the response registry covers every concrete OutputTarget subclass.
|
||||
|
||||
Runs at module import. Surfaces a missing builder eagerly instead of
|
||||
letting a request fall through to the previous silent fallback (which
|
||||
used to return a defaults-filled LedOutputTargetResponse and quietly
|
||||
misshape the payload for unknown target types).
|
||||
"""
|
||||
expected = {WledOutputTarget, HALightOutputTarget, Z2MLightOutputTarget}
|
||||
registered = set(_TARGET_RESPONSE_BUILDERS.keys())
|
||||
missing = expected - registered
|
||||
extra = registered - expected
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing builders: {sorted(c.__name__ for c in missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
|
||||
raise RuntimeError(
|
||||
"_TARGET_RESPONSE_BUILDERS is out of sync with the OutputTarget "
|
||||
"subclass set: " + "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_target_response_coverage()
|
||||
|
||||
|
||||
def _target_to_response(target) -> OutputTargetResponse:
|
||||
"""Convert any OutputTarget to the appropriate typed response.
|
||||
|
||||
Dispatches via :data:`_TARGET_RESPONSE_BUILDERS` keyed by concrete
|
||||
subclass. Raises ``RuntimeError`` for an unregistered subclass —
|
||||
coverage is asserted at import, so this should never fire in
|
||||
practice; if it does, the storage layer added a new OutputTarget
|
||||
subclass without a matching response builder here.
|
||||
"""
|
||||
builder = _TARGET_RESPONSE_BUILDERS.get(type(target))
|
||||
if builder is None:
|
||||
raise RuntimeError(
|
||||
f"No response builder registered for OutputTarget subclass " f"{type(target).__name__}"
|
||||
)
|
||||
return builder(target)
|
||||
|
||||
|
||||
# ===== CRUD ENDPOINTS =====
|
||||
|
||||
|
||||
def _build_ha_mappings(
|
||||
payload: list[HALightMappingSchema] | None,
|
||||
) -> list[HALightMapping] | None:
|
||||
if not payload:
|
||||
return None
|
||||
return [
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in payload
|
||||
]
|
||||
|
||||
|
||||
def _build_z2m_mappings(
|
||||
payload: list[Z2MLightMappingSchema] | None,
|
||||
) -> list[Z2MLightMapping] | None:
|
||||
if not payload:
|
||||
return None
|
||||
return [
|
||||
Z2MLightMapping(
|
||||
friendly_name=m.friendly_name,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in payload
|
||||
]
|
||||
|
||||
|
||||
def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
|
||||
if not device_id:
|
||||
return
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
|
||||
def _validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str) -> None:
|
||||
"""Ensure the referenced MQTT source exists. Empty id is allowed (unconfigured)."""
|
||||
if not mqtt_source_id:
|
||||
return
|
||||
try:
|
||||
mqtt_store.get(mqtt_source_id)
|
||||
except (ValueError, EntityNotFoundError):
|
||||
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
|
||||
)
|
||||
@@ -119,53 +289,70 @@ async def create_target(
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
value_source_store: ValueSourceStore = Depends(get_value_source_store),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Create a new output target."""
|
||||
try:
|
||||
# Validate device exists if provided
|
||||
device_id = getattr(data, "device_id", "")
|
||||
if device_id:
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = (
|
||||
[
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
match data:
|
||||
case LedOutputTargetCreate():
|
||||
_validate_device_exists(device_store, data.device_id)
|
||||
target = target_store.create_wled_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness=data.brightness,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
adaptive_fps=data.adaptive_fps,
|
||||
protocol=data.protocol,
|
||||
)
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
if ha_light_mappings_raw
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in store
|
||||
target = target_store.create_target(
|
||||
name=data.name,
|
||||
target_type=data.target_type,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=getattr(data, "color_strip_source_id", ""),
|
||||
brightness=getattr(data, "brightness", 1.0),
|
||||
fps=getattr(data, "fps", 30),
|
||||
keepalive_interval=getattr(data, "keepalive_interval", 1.0),
|
||||
state_check_interval=getattr(data, "state_check_interval", 30),
|
||||
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
|
||||
adaptive_fps=getattr(data, "adaptive_fps", False),
|
||||
protocol=getattr(data, "protocol", "ddp"),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=getattr(data, "ha_source_id", ""),
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=getattr(data, "update_rate", 2.0),
|
||||
transition=getattr(data, "transition", 0.5),
|
||||
color_tolerance=getattr(data, "color_tolerance", 5),
|
||||
)
|
||||
case HALightOutputTargetCreate():
|
||||
if data.source_kind == "color_vs":
|
||||
_validate_color_value_source(value_source_store, data.color_value_source_id)
|
||||
target = target_store.create_ha_light_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=data.ha_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
case Z2MLightOutputTargetCreate():
|
||||
if data.source_kind == "color_vs":
|
||||
_validate_color_value_source(value_source_store, data.color_value_source_id)
|
||||
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
||||
target = target_store.create_z2m_light_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
mqtt_source_id=data.mqtt_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
|
||||
base_topic=data.base_topic,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
|
||||
raise HTTPException(status_code=400, detail="Unknown target_type")
|
||||
|
||||
# Register in processor manager
|
||||
try:
|
||||
@@ -233,6 +420,18 @@ async def get_target(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
def _resolve_effective_color_vs_id(
|
||||
target_store: OutputTargetStore, target_id: str, payload_id: str | None
|
||||
) -> str:
|
||||
if payload_id is not None:
|
||||
return payload_id
|
||||
try:
|
||||
existing = target_store.get_target(target_id)
|
||||
except ValueError:
|
||||
return ""
|
||||
return getattr(existing, "color_value_source_id", "") or ""
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
|
||||
)
|
||||
@@ -243,90 +442,161 @@ async def update_target(
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
value_source_store: ValueSourceStore = Depends(get_value_source_store),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Update a output target."""
|
||||
try:
|
||||
# Validate device exists if changing
|
||||
device_id = getattr(data, "device_id", None)
|
||||
if device_id is not None and device_id:
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
css_changed = False
|
||||
brightness_changed = False
|
||||
settings_changed = False
|
||||
device_changed = False
|
||||
|
||||
# Build HA light mappings if provided
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = None
|
||||
if ha_light_mappings_raw is not None:
|
||||
ha_mappings = [
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
match data:
|
||||
case LedOutputTargetUpdate():
|
||||
if data.device_id:
|
||||
_validate_device_exists(device_store, data.device_id)
|
||||
target = target_store.update_wled_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness=data.brightness,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
adaptive_fps=data.adaptive_fps,
|
||||
protocol=data.protocol,
|
||||
)
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
|
||||
# Update in store
|
||||
target = target_store.update_target(
|
||||
target_id=target_id,
|
||||
name=data.name,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=getattr(data, "color_strip_source_id", None),
|
||||
brightness=getattr(data, "brightness", None),
|
||||
fps=getattr(data, "fps", None),
|
||||
keepalive_interval=getattr(data, "keepalive_interval", None),
|
||||
state_check_interval=getattr(data, "state_check_interval", None),
|
||||
min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
|
||||
adaptive_fps=getattr(data, "adaptive_fps", None),
|
||||
protocol=getattr(data, "protocol", None),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=getattr(data, "ha_source_id", None),
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=getattr(data, "update_rate", None),
|
||||
transition=getattr(data, "transition", None),
|
||||
color_tolerance=getattr(data, "color_tolerance", None),
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.fps,
|
||||
data.keepalive_interval,
|
||||
data.state_check_interval,
|
||||
data.min_brightness_threshold,
|
||||
data.adaptive_fps,
|
||||
data.brightness,
|
||||
)
|
||||
)
|
||||
device_changed = data.device_id is not None
|
||||
case HALightOutputTargetUpdate():
|
||||
# Validate color VS when switching into / staying in color_vs mode
|
||||
if data.source_kind == "color_vs" or (
|
||||
data.source_kind is None and data.color_value_source_id
|
||||
):
|
||||
effective_id = _resolve_effective_color_vs_id(
|
||||
target_store, target_id, data.color_value_source_id
|
||||
)
|
||||
_validate_color_value_source(value_source_store, effective_id)
|
||||
target = target_store.update_ha_light_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
ha_source_id=data.ha_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.source_kind,
|
||||
data.color_value_source_id,
|
||||
data.brightness,
|
||||
data.update_rate,
|
||||
data.transition,
|
||||
data.min_brightness_threshold,
|
||||
data.color_tolerance,
|
||||
data.ha_light_mappings,
|
||||
data.stop_action,
|
||||
)
|
||||
)
|
||||
case Z2MLightOutputTargetUpdate():
|
||||
if data.source_kind == "color_vs" or (
|
||||
data.source_kind is None and data.color_value_source_id
|
||||
):
|
||||
effective_id = _resolve_effective_color_vs_id(
|
||||
target_store, target_id, data.color_value_source_id
|
||||
)
|
||||
_validate_color_value_source(value_source_store, effective_id)
|
||||
if data.mqtt_source_id:
|
||||
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
||||
target = target_store.update_z2m_light_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
mqtt_source_id=data.mqtt_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
|
||||
base_topic=data.base_topic,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.source_kind,
|
||||
data.color_value_source_id,
|
||||
data.mqtt_source_id,
|
||||
data.brightness,
|
||||
data.base_topic,
|
||||
data.update_rate,
|
||||
data.transition,
|
||||
data.min_brightness_threshold,
|
||||
data.color_tolerance,
|
||||
data.z2m_light_mappings,
|
||||
data.stop_action,
|
||||
)
|
||||
)
|
||||
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
|
||||
raise HTTPException(status_code=400, detail="Unknown target_type")
|
||||
|
||||
# Sync processor manager (run in thread — css release/acquire can block)
|
||||
color_strip_source_id = getattr(data, "color_strip_source_id", None)
|
||||
fps = getattr(data, "fps", None)
|
||||
keepalive_interval = getattr(data, "keepalive_interval", None)
|
||||
state_check_interval = getattr(data, "state_check_interval", None)
|
||||
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
|
||||
adaptive_fps = getattr(data, "adaptive_fps", None)
|
||||
update_rate = getattr(data, "update_rate", None)
|
||||
transition = getattr(data, "transition", None)
|
||||
color_tolerance = getattr(data, "color_tolerance", None)
|
||||
brightness = getattr(data, "brightness", None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
target.sync_with_manager,
|
||||
manager,
|
||||
settings_changed=(
|
||||
fps is not None
|
||||
or keepalive_interval is not None
|
||||
or state_check_interval is not None
|
||||
or min_brightness_threshold is not None
|
||||
or adaptive_fps is not None
|
||||
or update_rate is not None
|
||||
or transition is not None
|
||||
or color_tolerance is not None
|
||||
or ha_light_mappings_raw is not None
|
||||
or brightness is not None
|
||||
),
|
||||
css_changed=color_strip_source_id is not None,
|
||||
brightness_changed=brightness is not None,
|
||||
settings_changed=settings_changed,
|
||||
css_changed=css_changed,
|
||||
brightness_changed=brightness_changed,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
|
||||
pass
|
||||
|
||||
# Device change requires async stop -> swap -> start cycle
|
||||
if device_id is not None:
|
||||
# LED-only: device change requires async stop -> swap -> start cycle
|
||||
if device_changed and isinstance(target, WledOutputTarget):
|
||||
try:
|
||||
await manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -335,6 +335,35 @@ async def get_overlay_status(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
# ===== HA LIGHT — MANUAL TURN OFF =====
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/ha-light/turn-off", tags=["Processing"])
|
||||
async def turn_off_ha_light_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Turn off all HA light entities mapped by the target.
|
||||
|
||||
Works regardless of whether the target's processor is running. Useful
|
||||
when ``stop_action`` is ``"none"`` and lights were left on after a stop.
|
||||
"""
|
||||
try:
|
||||
# Verify target exists
|
||||
target_store.get_target(target_id)
|
||||
count = await manager.turn_off_ha_light_target(target_id)
|
||||
return {"status": "ok", "target_id": target_id, "entities": count}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to turn off HA lights: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== HA LIGHT COLOR PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
@@ -377,6 +406,75 @@ async def ha_light_colors_ws(
|
||||
manager.remove_ha_light_ws_client(target_id, websocket)
|
||||
|
||||
|
||||
# ===== Z2M LIGHT — MANUAL TURN OFF =====
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/z2m-light/turn-off", tags=["Processing"])
|
||||
async def turn_off_z2m_light_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Publish OFF to all Z2M bulbs mapped by the target.
|
||||
|
||||
Works regardless of whether the target's processor is running. Useful
|
||||
when ``stop_action`` is ``"none"`` and bulbs were left on after a stop.
|
||||
"""
|
||||
try:
|
||||
target_store.get_target(target_id)
|
||||
count = await manager.turn_off_z2m_light_target(target_id)
|
||||
return {"status": "ok", "target_id": target_id, "entities": count}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to turn off Z2M lights: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== Z2M LIGHT COLOR PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/z2m-light/ws")
|
||||
async def z2m_light_colors_ws(
|
||||
websocket: WebSocket,
|
||||
target_id: str,
|
||||
):
|
||||
"""WebSocket for live Z2M bulb colour preview.
|
||||
|
||||
Streams: {"type":"colors_update","colors":{friendly_name:{r,g,b,hex},...}}
|
||||
at the target's update_rate. Auth via first-message handshake.
|
||||
"""
|
||||
from ledgrab.api.auth import accept_and_authenticate_ws
|
||||
|
||||
if await accept_and_authenticate_ws(websocket) is None:
|
||||
return
|
||||
|
||||
manager: ProcessorManager = get_processor_manager()
|
||||
|
||||
try:
|
||||
proc = manager._processors.get(target_id)
|
||||
if not proc or not proc.is_running:
|
||||
await websocket.close(code=4003, reason="Target not running")
|
||||
return
|
||||
except Exception as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
try:
|
||||
manager.add_z2m_light_ws_client(target_id, websocket)
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except (RuntimeError, ConnectionError) as e:
|
||||
logger.debug("ws closed in z2m-light client: %s", e)
|
||||
finally:
|
||||
manager.remove_z2m_light_ws_client(target_id, websocket)
|
||||
|
||||
|
||||
# ===== LED PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -83,6 +85,8 @@ async def create_pattern_template(
|
||||
rectangles=rectangles,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("pattern_template", "created", template.id)
|
||||
return _pat_template_to_response(template)
|
||||
@@ -139,6 +143,8 @@ async def update_pattern_template(
|
||||
rectangles=rectangles,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("pattern_template", "updated", template_id)
|
||||
return _pat_template_to_response(template)
|
||||
|
||||
@@ -12,6 +12,7 @@ from fastapi.responses import Response
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_picture_source_store,
|
||||
get_output_target_store,
|
||||
get_pp_template_store,
|
||||
@@ -37,6 +38,7 @@ from ledgrab.api.schemas.picture_sources import (
|
||||
)
|
||||
from ledgrab.core.capture_engines import EngineRegistry
|
||||
from ledgrab.core.filters import FilterRegistry, ImagePool
|
||||
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.storage.template_store import TemplateStore
|
||||
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
@@ -63,6 +65,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
display_index=s.display_index,
|
||||
capture_template_id=s.capture_template_id,
|
||||
target_fps=s.target_fps,
|
||||
@@ -74,6 +78,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
source_stream_id=s.source_stream_id,
|
||||
postprocessing_template_id=s.postprocessing_template_id,
|
||||
),
|
||||
@@ -84,6 +90,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
image_asset_id=s.image_asset_id,
|
||||
),
|
||||
VideoCaptureSource: lambda s: VideoPictureSourceResponse(
|
||||
@@ -93,6 +101,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
video_asset_id=s.video_asset_id,
|
||||
loop=s.loop,
|
||||
playback_speed=s.playback_speed,
|
||||
@@ -361,11 +371,12 @@ async def delete_picture_source(
|
||||
_auth: AuthRequired,
|
||||
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Delete a picture source."""
|
||||
try:
|
||||
# Check if any target references this stream
|
||||
target_names = store.get_targets_referencing(stream_id, target_store)
|
||||
# Check if any target transitively references this stream via a CSS
|
||||
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
|
||||
if target_names:
|
||||
names = ", ".join(target_names)
|
||||
raise HTTPException(
|
||||
@@ -373,6 +384,16 @@ async def delete_picture_source(
|
||||
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
|
||||
"Please reassign those targets before deleting.",
|
||||
)
|
||||
# Block when any CSS still references this picture source, even if no
|
||||
# target depends on it — deletion would leave the CSS broken.
|
||||
css_refs = css_store.get_referencing_picture_source(stream_id)
|
||||
if css_refs:
|
||||
css_names = ", ".join(css.name for css in css_refs)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot delete picture source: it is used by color strip source(s): "
|
||||
f"{css_names}. Please reassign or delete those first.",
|
||||
)
|
||||
store.delete_stream(stream_id)
|
||||
fire_entity_event("picture_source", "deleted", stream_id)
|
||||
except HTTPException:
|
||||
|
||||
@@ -49,6 +49,8 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -86,6 +88,8 @@ async def create_pp_template(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("pp_template", "created", template.id)
|
||||
return _pp_template_to_response(template)
|
||||
@@ -143,6 +147,8 @@ async def update_pp_template(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("pp_template", "updated", template_id)
|
||||
return _pp_template_to_response(template)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""User preferences routes — dashboard layout + notification settings + daylight tz.
|
||||
|
||||
The dashboard layout schema is owned by the frontend (open registry of
|
||||
section/cell keys); the backend treats the value as an opaque JSON blob,
|
||||
validates it's a dict with a `version` field, and persists it under the
|
||||
`dashboard_layout` settings key.
|
||||
|
||||
Notification preferences are validated server-side via Pydantic so the
|
||||
backend can read them when deciding whether to start the background
|
||||
discovery watcher.
|
||||
|
||||
Daylight timezone is a single global IANA tz name shared by every
|
||||
daylight value-source / color-strip-source. Stored as
|
||||
``{"value": "Europe/Berlin"}`` under the ``daylight_timezone`` key, with
|
||||
empty/missing meaning "use system local time".
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import get_database
|
||||
from ledgrab.api.schemas.preferences import NotificationPreferences
|
||||
from ledgrab.core.processing.daylight_settings import (
|
||||
DAYLIGHT_TIMEZONE_KEY,
|
||||
get_daylight_timezone,
|
||||
set_daylight_timezone,
|
||||
)
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
||||
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
||||
_CARD_MODES_KEY = "card_modes"
|
||||
|
||||
|
||||
class DaylightTimezonePreference(BaseModel):
|
||||
"""Global IANA timezone applied to every daylight cycle source."""
|
||||
|
||||
timezone: str = Field("", description="IANA timezone name; empty = system local")
|
||||
|
||||
|
||||
def load_notification_preferences(db: Database | None = None) -> NotificationPreferences:
|
||||
"""Read notification prefs, returning defaults when unset or corrupt.
|
||||
|
||||
Used by both the route handler and `main.lifespan` (so the discovery
|
||||
watcher can decide whether to start without going through HTTP).
|
||||
"""
|
||||
if db is None:
|
||||
from ledgrab.api.dependencies import get_database as _get_db
|
||||
|
||||
db = _get_db()
|
||||
raw = db.get_setting(_NOTIFICATION_PREFS_KEY)
|
||||
if not raw:
|
||||
return NotificationPreferences()
|
||||
try:
|
||||
return NotificationPreferences.model_validate(raw)
|
||||
except Exception as e:
|
||||
logger.warning("Stored notification preferences invalid (%s); using defaults", e)
|
||||
return NotificationPreferences()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/dashboard-layout",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_dashboard_layout(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, Any]:
|
||||
"""Read the saved dashboard layout. Returns an empty object when no
|
||||
layout has been saved yet — the frontend falls back to its built-in
|
||||
default in that case."""
|
||||
value = db.get_setting(_DASHBOARD_LAYOUT_KEY)
|
||||
return value if value is not None else {}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/dashboard-layout",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_dashboard_layout(
|
||||
_: AuthRequired,
|
||||
body: dict[str, Any] = Body(...),
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, bool]:
|
||||
"""Save the dashboard layout. The body must be a JSON object with a
|
||||
numeric `version` field; everything else is treated as opaque payload
|
||||
that the frontend will validate on read."""
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=422, detail="Body must be a JSON object")
|
||||
if not isinstance(body.get("version"), int):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Layout must include a numeric 'version' field",
|
||||
)
|
||||
db.set_setting(_DASHBOARD_LAYOUT_KEY, body)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/preferences/dashboard-layout",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def delete_dashboard_layout(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, bool]:
|
||||
"""Delete the saved layout — frontend will revert to the default
|
||||
on next load. Used by the 'Reset' button when the user wants
|
||||
to clear the server-side override entirely."""
|
||||
db.set_setting(_DASHBOARD_LAYOUT_KEY, {})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/notifications",
|
||||
response_model=NotificationPreferences,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_notification_preferences(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> NotificationPreferences:
|
||||
"""Read notification prefs, returning defaults when unset.
|
||||
|
||||
Defaults: device_offline=both, device_online/discovered=snack,
|
||||
device_lost=none, background discovery on, 10 s startup grace,
|
||||
5 s flap debounce.
|
||||
"""
|
||||
return load_notification_preferences(db)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/notifications",
|
||||
response_model=NotificationPreferences,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_notification_preferences(
|
||||
_: AuthRequired,
|
||||
body: NotificationPreferences,
|
||||
db: Database = Depends(get_database),
|
||||
) -> NotificationPreferences:
|
||||
"""Persist the notification prefs. Pydantic enforces channel
|
||||
enum + grace/debounce ranges so a bad client cannot poison
|
||||
the stored value."""
|
||||
db.set_setting(_NOTIFICATION_PREFS_KEY, body.model_dump())
|
||||
logger.info(
|
||||
"Notification preferences updated (background_discovery=%s, " "channels=%s)",
|
||||
body.background_discovery_enabled,
|
||||
body.channels.model_dump(),
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Card presentation modes (per-surface comfortable/compact/dense)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_VALID_CARD_MODES = {"comfortable", "compact", "dense", "row"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/card-modes",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_card_modes(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, Any]:
|
||||
"""Read the saved card-mode preferences. Returns an empty object when
|
||||
nothing has been saved yet — the frontend falls back to the default
|
||||
mode ("compact") for every surface in that case."""
|
||||
value = db.get_setting(_CARD_MODES_KEY)
|
||||
return value if value is not None else {}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/card-modes",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_card_modes(
|
||||
_: AuthRequired,
|
||||
body: dict[str, Any] = Body(...),
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, bool]:
|
||||
"""Save card-mode preferences. The body must be a JSON object shaped
|
||||
like ``{"version": 1, "surfaces": {"<surface>": "<mode>", …}}``.
|
||||
|
||||
The surface registry is intentionally open (any string accepted) so
|
||||
new card surfaces can adopt the toggle without a server migration.
|
||||
Invalid mode values are rejected to prevent a bad client from
|
||||
poisoning the stored value."""
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=422, detail="Body must be a JSON object")
|
||||
if not isinstance(body.get("version"), int):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Body must include a numeric 'version' field",
|
||||
)
|
||||
surfaces = body.get("surfaces", {})
|
||||
if not isinstance(surfaces, dict):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="'surfaces' must be an object mapping surface keys to modes",
|
||||
)
|
||||
for key, mode in surfaces.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Surface keys must be non-empty strings (got {key!r})",
|
||||
)
|
||||
if mode not in _VALID_CARD_MODES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Surface {key!r} has invalid mode {mode!r}; "
|
||||
f"expected one of {sorted(_VALID_CARD_MODES)}"
|
||||
),
|
||||
)
|
||||
db.set_setting(_CARD_MODES_KEY, body)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/preferences/card-modes",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def delete_card_modes(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, bool]:
|
||||
"""Delete saved card-mode preferences — every surface reverts to the
|
||||
frontend default on next load."""
|
||||
db.set_setting(_CARD_MODES_KEY, {})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Daylight timezone (global)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/daylight-timezone",
|
||||
response_model=DaylightTimezonePreference,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_daylight_timezone_preference(
|
||||
_: AuthRequired,
|
||||
) -> DaylightTimezonePreference:
|
||||
"""Return the global daylight cycle timezone (empty = system local)."""
|
||||
return DaylightTimezonePreference(timezone=get_daylight_timezone())
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/daylight-timezone",
|
||||
response_model=DaylightTimezonePreference,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_daylight_timezone_preference(
|
||||
_: AuthRequired,
|
||||
body: DaylightTimezonePreference,
|
||||
) -> DaylightTimezonePreference:
|
||||
"""Persist the global daylight cycle timezone.
|
||||
|
||||
The string is stored verbatim — clients should send a valid IANA name
|
||||
(e.g. ``Europe/Berlin``) or an empty string for "use server local".
|
||||
Daylight streams pick up the new value within ~1 second.
|
||||
"""
|
||||
saved = set_daylight_timezone(body.timezone)
|
||||
logger.info("Daylight timezone updated: %r", saved or "<system local>")
|
||||
return DaylightTimezonePreference(timezone=saved)
|
||||
|
||||
|
||||
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
|
||||
@@ -51,6 +51,8 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
|
||||
],
|
||||
order=preset.order,
|
||||
tags=preset.tags,
|
||||
icon=getattr(preset, "icon", "") or "",
|
||||
icon_color=getattr(preset, "icon_color", "") or "",
|
||||
created_at=preset.created_at,
|
||||
updated_at=preset.updated_at,
|
||||
)
|
||||
@@ -84,6 +86,8 @@ async def create_scene_preset(
|
||||
targets=targets,
|
||||
order=store.count(),
|
||||
tags=data.tags if data.tags is not None else [],
|
||||
icon=data.icon or "",
|
||||
icon_color=data.icon_color or "",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -182,6 +186,8 @@ async def update_scene_preset(
|
||||
order=data.order,
|
||||
targets=new_targets,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -8,6 +8,7 @@ from ledgrab.api.dependencies import (
|
||||
get_color_strip_store,
|
||||
get_sync_clock_manager,
|
||||
get_sync_clock_store,
|
||||
get_value_source_store,
|
||||
)
|
||||
from ledgrab.api.schemas.sync_clocks import (
|
||||
SyncClockCreate,
|
||||
@@ -18,6 +19,7 @@ from ledgrab.api.schemas.sync_clocks import (
|
||||
from ledgrab.storage.sync_clock import SyncClock
|
||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
@@ -36,6 +38,8 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
|
||||
speed=rt.speed if rt else clock.speed,
|
||||
description=clock.description,
|
||||
tags=clock.tags,
|
||||
icon=getattr(clock, "icon", "") or "",
|
||||
icon_color=getattr(clock, "icon_color", "") or "",
|
||||
is_running=rt.is_running if rt else True,
|
||||
elapsed_time=rt.get_time() if rt else 0.0,
|
||||
created_at=clock.created_at,
|
||||
@@ -73,6 +77,8 @@ async def create_sync_clock(
|
||||
speed=data.speed,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("sync_clock", "created", clock.id)
|
||||
return _to_response(clock, manager)
|
||||
@@ -118,6 +124,8 @@ async def update_sync_clock(
|
||||
speed=data.speed,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
# Hot-update runtime speed
|
||||
if data.speed is not None:
|
||||
@@ -137,14 +145,18 @@ async def delete_sync_clock(
|
||||
_auth: AuthRequired,
|
||||
store: SyncClockStore = Depends(get_sync_clock_store),
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
vs_store: ValueSourceStore = Depends(get_value_source_store),
|
||||
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||
):
|
||||
"""Delete a synchronization clock (fails if referenced by CSS sources)."""
|
||||
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
|
||||
try:
|
||||
# Check references
|
||||
for source in css_store.get_all_sources():
|
||||
if getattr(source, "clock_id", None) == clock_id:
|
||||
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
||||
for vs in vs_store.get_all_sources():
|
||||
if getattr(vs, "clock_id", None) == clock_id:
|
||||
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
|
||||
manager.release_all_for(clock_id)
|
||||
store.delete_clock(clock_id)
|
||||
fire_entity_event("sync_clock", "deleted", clock_id)
|
||||
|
||||
@@ -7,8 +7,8 @@ import asyncio
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import os
|
||||
|
||||
@@ -24,6 +24,7 @@ from ledgrab.api.dependencies import (
|
||||
get_device_store,
|
||||
get_ha_manager,
|
||||
get_ha_store,
|
||||
get_mqtt_manager,
|
||||
get_output_target_store,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
@@ -92,6 +93,13 @@ def _get_cpu_name() -> str | None:
|
||||
|
||||
_cpu_name: str | None = _get_cpu_name()
|
||||
|
||||
# Captured at first import of this module. Process-wide elapsed time is
|
||||
# the closest the server has to "app start" without instrumenting main.py;
|
||||
# the system module is imported during router setup, before the server
|
||||
# accepts requests, so the drift is negligible. Used by /health to expose
|
||||
# uptime_seconds for the transport-bar ticker.
|
||||
_APP_START_MONOTONIC: float = time.monotonic()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -122,6 +130,7 @@ async def health_check(request: Request):
|
||||
setup_required=setup_required,
|
||||
repo_url=REPO_URL,
|
||||
donate_url=DONATE_URL,
|
||||
uptime_seconds=time.monotonic() - _APP_START_MONOTONIC,
|
||||
)
|
||||
|
||||
|
||||
@@ -180,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.
|
||||
|
||||
@@ -316,6 +325,15 @@ def get_system_performance(_: AuthRequired):
|
||||
except Exception as e:
|
||||
logger.debug("NVML query failed: %s", e)
|
||||
|
||||
# Windows has no user-space CPU die temperature source without a kernel
|
||||
# driver. We rely on LibreHardwareMonitor / OpenHardwareMonitor publishing
|
||||
# WMI sensors when the user runs them. When no reading arrives, surface
|
||||
# that explicitly so the dashboard can show a "here's how to enable it"
|
||||
# hint instead of silently hiding the card.
|
||||
cpu_temp_hint_key: str | None = None
|
||||
if thermals.cpu_temp_c is None and platform.system() == "Windows":
|
||||
cpu_temp_hint_key = "dashboard.perf.temp.install_lhm"
|
||||
|
||||
return PerformanceResponse(
|
||||
cpu_name=_cpu_name,
|
||||
cpu_percent=metrics.cpu_percent(),
|
||||
@@ -328,6 +346,7 @@ def get_system_performance(_: AuthRequired):
|
||||
battery_percent=thermals.battery_percent,
|
||||
battery_temp_c=thermals.battery_temp_c,
|
||||
cpu_temp_c=thermals.cpu_temp_c,
|
||||
cpu_temp_hint_key=cpu_temp_hint_key,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
@@ -361,22 +380,20 @@ async def get_integrations_status(
|
||||
_: AuthRequired,
|
||||
ha_store=Depends(get_ha_store),
|
||||
ha_manager=Depends(get_ha_manager),
|
||||
mqtt_manager=Depends(get_mqtt_manager),
|
||||
):
|
||||
"""Return connection status for external integrations (MQTT, Home Assistant).
|
||||
|
||||
Used by the dashboard to show connectivity indicators.
|
||||
Used by the dashboard to show connectivity indicators. MQTT is reported
|
||||
per-source since the multi-broker refactor — no more global "MQTT
|
||||
enabled" flag.
|
||||
"""
|
||||
from ledgrab.core.devices.mqtt_client import get_mqtt_service
|
||||
|
||||
# MQTT status
|
||||
mqtt_service = get_mqtt_service()
|
||||
mqtt_config = get_config().mqtt
|
||||
# MQTT status — one entry per configured source
|
||||
mqtt_items = mqtt_manager.get_all_sources_status()
|
||||
mqtt_status = {
|
||||
"enabled": mqtt_config.enabled,
|
||||
"connected": mqtt_service.is_connected if mqtt_service else False,
|
||||
"broker": (
|
||||
f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None
|
||||
),
|
||||
"sources": mqtt_items,
|
||||
"total": len(mqtt_items),
|
||||
"connected": sum(1 for s in mqtt_items if s.get("connected")),
|
||||
}
|
||||
|
||||
# Home Assistant status
|
||||
|
||||
@@ -19,6 +19,9 @@ from ledgrab.api.schemas.system import (
|
||||
LogLevelResponse,
|
||||
MQTTSettingsRequest,
|
||||
MQTTSettingsResponse,
|
||||
ShutdownAction,
|
||||
ShutdownActionRequest,
|
||||
ShutdownActionResponse,
|
||||
)
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.storage.database import Database
|
||||
@@ -150,6 +153,55 @@ async def update_external_url(
|
||||
return ExternalUrlResponse(external_url=url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shutdown action setting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_SHUTDOWN_ACTIONS: tuple[str, ...] = ("stop_targets", "nothing")
|
||||
_DEFAULT_SHUTDOWN_ACTION: ShutdownAction = "stop_targets"
|
||||
|
||||
|
||||
def load_shutdown_action(db: Database | None = None) -> ShutdownAction:
|
||||
"""Load the configured shutdown action. Returns the default if unset or corrupt."""
|
||||
if db is None:
|
||||
from ledgrab.api.dependencies import get_database
|
||||
|
||||
db = get_database()
|
||||
data = db.get_setting("shutdown_action")
|
||||
if not data:
|
||||
return _DEFAULT_SHUTDOWN_ACTION
|
||||
value = data.get("action")
|
||||
if value in _VALID_SHUTDOWN_ACTIONS:
|
||||
return value # type: ignore[return-value]
|
||||
return _DEFAULT_SHUTDOWN_ACTION
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/shutdown-action",
|
||||
response_model=ShutdownActionResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def get_shutdown_action(_: AuthRequired, db: Database = Depends(get_database)):
|
||||
"""Get the configured server shutdown action."""
|
||||
return ShutdownActionResponse(action=load_shutdown_action(db))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/system/shutdown-action",
|
||||
response_model=ShutdownActionResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def update_shutdown_action(
|
||||
_: AuthRequired,
|
||||
body: ShutdownActionRequest,
|
||||
db: Database = Depends(get_database),
|
||||
):
|
||||
"""Set what happens to LED targets when the server shuts down."""
|
||||
db.set_setting("shutdown_action", {"action": body.action})
|
||||
logger.info("Shutdown action updated: %s", body.action)
|
||||
return ShutdownActionResponse(action=body.action)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live log viewer WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -45,6 +45,21 @@ logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _template_to_response(t) -> TemplateResponse:
|
||||
return TemplateResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
engine_type=t.engine_type,
|
||||
engine_config=t.engine_config,
|
||||
tags=t.tags,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
# ===== CAPTURE TEMPLATE ENDPOINTS =====
|
||||
|
||||
|
||||
@@ -57,19 +72,7 @@ async def list_templates(
|
||||
try:
|
||||
templates = template_store.get_all_templates()
|
||||
|
||||
template_responses = [
|
||||
TemplateResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
engine_type=t.engine_type,
|
||||
engine_config=t.engine_config,
|
||||
tags=t.tags,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
)
|
||||
for t in templates
|
||||
]
|
||||
template_responses = [_template_to_response(t) for t in templates]
|
||||
|
||||
return TemplateListResponse(
|
||||
templates=template_responses,
|
||||
@@ -100,19 +103,12 @@ async def create_template(
|
||||
engine_config=template_data.engine_config,
|
||||
description=template_data.description,
|
||||
tags=template_data.tags,
|
||||
icon=template_data.icon,
|
||||
icon_color=template_data.icon_color,
|
||||
)
|
||||
|
||||
fire_entity_event("capture_template", "created", template.id)
|
||||
return TemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
tags=template.tags,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
)
|
||||
return _template_to_response(template)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -138,16 +134,7 @@ async def get_template(
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
|
||||
|
||||
return TemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
tags=template.tags,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
)
|
||||
return _template_to_response(template)
|
||||
|
||||
|
||||
@router.put(
|
||||
@@ -168,19 +155,12 @@ async def update_template(
|
||||
engine_config=update_data.engine_config,
|
||||
description=update_data.description,
|
||||
tags=update_data.tags,
|
||||
icon=update_data.icon,
|
||||
icon_color=update_data.icon_color,
|
||||
)
|
||||
|
||||
fire_entity_event("capture_template", "updated", template_id)
|
||||
return TemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
tags=template.tags,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
)
|
||||
return _template_to_response(template)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -255,6 +235,7 @@ async def list_engines(_auth: AuthRequired):
|
||||
type=engine_type,
|
||||
name=engine_type.upper(),
|
||||
default_config=engine_class.get_default_config(),
|
||||
config_choices=engine_class.get_config_choices(),
|
||||
available=(engine_type in available_set),
|
||||
has_own_displays=getattr(engine_class, "HAS_OWN_DISPLAYS", False),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Value source routes: CRUD for value sources."""
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated, Optional
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -23,6 +23,7 @@ from ledgrab.api.schemas.value_sources import (
|
||||
DaylightValueSourceResponse,
|
||||
GradientMapValueSourceResponse,
|
||||
HAEntityValueSourceResponse,
|
||||
HTTPValueSourceResponse,
|
||||
StaticColorValueSourceResponse,
|
||||
StaticValueSourceResponse,
|
||||
SystemMetricsValueSourceResponse,
|
||||
@@ -41,6 +42,7 @@ from ledgrab.storage.value_source import (
|
||||
DaylightValueSource,
|
||||
GradientMapValueSource,
|
||||
HAEntityValueSource,
|
||||
HTTPValueSource,
|
||||
StaticColorValueSource,
|
||||
StaticValueSource,
|
||||
SystemMetricsValueSource,
|
||||
@@ -64,6 +66,8 @@ _RESPONSE_MAP = {
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
value=s.value,
|
||||
@@ -73,6 +77,8 @@ _RESPONSE_MAP = {
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
waveform=s.waveform,
|
||||
@@ -85,6 +91,8 @@ _RESPONSE_MAP = {
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
audio_source_id=s.audio_source_id,
|
||||
@@ -100,11 +108,14 @@ _RESPONSE_MAP = {
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
speed=s.speed,
|
||||
use_real_time=s.use_real_time,
|
||||
latitude=s.latitude,
|
||||
longitude=s.longitude,
|
||||
min_value=s.min_value,
|
||||
max_value=s.max_value,
|
||||
),
|
||||
@@ -113,6 +124,8 @@ _RESPONSE_MAP = {
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
color=list(s.color),
|
||||
@@ -122,17 +135,22 @@ _RESPONSE_MAP = {
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
colors=[list(c) for c in s.colors],
|
||||
speed=s.speed,
|
||||
easing=s.easing,
|
||||
clock_id=s.clock_id,
|
||||
),
|
||||
AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
schedule=s.schedule,
|
||||
@@ -142,6 +160,8 @@ _RESPONSE_MAP = {
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
ha_source_id=s.ha_source_id,
|
||||
@@ -156,6 +176,8 @@ _RESPONSE_MAP = {
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
value_source_id=s.value_source_id,
|
||||
@@ -167,6 +189,8 @@ _RESPONSE_MAP = {
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
color_strip_source_id=s.color_strip_source_id,
|
||||
@@ -178,6 +202,8 @@ _RESPONSE_MAP = {
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
metric=s.metric,
|
||||
@@ -189,6 +215,22 @@ _RESPONSE_MAP = {
|
||||
poll_interval=s.poll_interval,
|
||||
smoothing=s.smoothing,
|
||||
),
|
||||
HTTPValueSource: lambda s: HTTPValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
http_endpoint_id=s.http_endpoint_id,
|
||||
json_path=s.json_path,
|
||||
interval_s=s.interval_s,
|
||||
min_value=s.min_value,
|
||||
max_value=s.max_value,
|
||||
smoothing=s.smoothing,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +244,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
|
||||
name=source.name,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
picture_source_id=source.picture_source_id,
|
||||
@@ -216,6 +260,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
|
||||
name=source.name,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
schedule=source.schedule,
|
||||
@@ -231,6 +277,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
|
||||
name=source.name,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
value=getattr(source, "value", 1.0),
|
||||
@@ -241,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",
|
||||
),
|
||||
|
||||
@@ -39,6 +39,8 @@ def _to_response(source: WeatherSource) -> WeatherSourceResponse:
|
||||
update_interval=d["update_interval"],
|
||||
description=d.get("description"),
|
||||
tags=d.get("tags", []),
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -79,6 +81,8 @@ async def create_weather_source(
|
||||
update_interval=data.update_interval,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -125,6 +129,8 @@ async def update_weather_source(
|
||||
update_interval=data.update_interval,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
|
||||
|
||||
@@ -30,6 +30,9 @@ _RATE_WINDOW = 60.0 # seconds
|
||||
_rate_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
_RATE_HITS_HARD_CAP = 1024
|
||||
|
||||
|
||||
def _check_rate_limit(client_ip: str) -> None:
|
||||
"""Raise 429 if *client_ip* exceeded the webhook rate limit."""
|
||||
now = time.time()
|
||||
@@ -44,11 +47,21 @@ def _check_rate_limit(client_ip: str) -> None:
|
||||
)
|
||||
_rate_hits[client_ip].append(now)
|
||||
|
||||
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth
|
||||
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth.
|
||||
if len(_rate_hits) > 100:
|
||||
stale = [ip for ip, ts in _rate_hits.items() if not ts or ts[-1] < window_start]
|
||||
for ip in stale:
|
||||
del _rate_hits[ip]
|
||||
# Hard cap as a final defence against an attacker spraying many distinct
|
||||
# X-Forwarded-For values to drive memory growth past the soft cleanup
|
||||
# threshold. Drop the oldest-touched IPs (by their latest timestamp).
|
||||
if len(_rate_hits) > _RATE_HITS_HARD_CAP:
|
||||
ordered = sorted(
|
||||
_rate_hits.items(),
|
||||
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
|
||||
)
|
||||
for ip, _ in ordered[: len(ordered) - _RATE_HITS_HARD_CAP]:
|
||||
_rate_hits.pop(ip, None)
|
||||
|
||||
|
||||
class WebhookPayload(BaseModel):
|
||||
|
||||
@@ -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,9 +9,19 @@ 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")
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
@@ -23,9 +33,19 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -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,19 +15,39 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AudioProcessingTemplateResponse(BaseModel):
|
||||
@@ -41,7 +61,17 @@ 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")
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AudioProcessingTemplateListResponse(BaseModel):
|
||||
|
||||
@@ -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,17 +15,27 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
@@ -35,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"),
|
||||
]
|
||||
|
||||
@@ -51,15 +59,25 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
@@ -69,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"),
|
||||
]
|
||||
|
||||
@@ -84,31 +100,39 @@ 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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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,18 +14,38 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AudioTemplateResponse(BaseModel):
|
||||
@@ -38,7 +58,17 @@ 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")
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AudioTemplateListResponse(BaseModel):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Automation-related schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -11,41 +11,55 @@ class RuleSchema(BaseModel):
|
||||
|
||||
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
|
||||
# Application rule fields
|
||||
apps: Optional[List[str]] = Field(None, description="Process names (for application rule)")
|
||||
match_type: Optional[str] = Field(
|
||||
apps: List[str] | None = Field(None, description="Process names (for application rule)")
|
||||
match_type: str | None = Field(
|
||||
None, description="'running' or 'topmost' (for application rule)"
|
||||
)
|
||||
# Time-of-day rule fields
|
||||
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day rule)")
|
||||
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day rule)")
|
||||
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
|
||||
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
|
||||
# System idle rule fields
|
||||
idle_minutes: Optional[int] = Field(
|
||||
idle_minutes: int | None = Field(
|
||||
None, description="Idle timeout in minutes (for system_idle rule)"
|
||||
)
|
||||
when_idle: Optional[bool] = Field(
|
||||
None, description="True=active when idle (for system_idle rule)"
|
||||
)
|
||||
when_idle: bool | None = Field(None, description="True=active when idle (for system_idle rule)")
|
||||
# Display state rule fields
|
||||
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)")
|
||||
state: str | None = Field(None, description="'on' or 'off' (for display_state rule)")
|
||||
# MQTT rule fields
|
||||
mqtt_source_id: Optional[str] = Field(None, description="MQTT source ID (for mqtt rule)")
|
||||
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt rule)")
|
||||
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)")
|
||||
match_mode: Optional[str] = Field(
|
||||
mqtt_source_id: str | None = Field(None, description="MQTT source ID (for mqtt rule)")
|
||||
topic: str | None = Field(None, description="MQTT topic to watch (for mqtt rule)")
|
||||
payload: str | None = Field(None, description="Expected payload value (for mqtt rule)")
|
||||
match_mode: str | None = Field(
|
||||
None, description="'exact', 'contains', or 'regex' (for mqtt rule)"
|
||||
)
|
||||
# Webhook rule fields
|
||||
token: Optional[str] = Field(
|
||||
None, description="Secret token for webhook URL (for webhook rule)"
|
||||
)
|
||||
token: str | None = Field(None, description="Secret token for webhook URL (for webhook rule)")
|
||||
# Home Assistant rule fields
|
||||
ha_source_id: Optional[str] = Field(
|
||||
ha_source_id: str | None = Field(
|
||||
None, description="Home Assistant source ID (for home_assistant rule)"
|
||||
)
|
||||
entity_id: Optional[str] = Field(
|
||||
entity_id: str | None = Field(
|
||||
None,
|
||||
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
|
||||
)
|
||||
# HTTP poll rule fields
|
||||
value_source_id: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Value source ID (for http_poll rule). The referenced "
|
||||
"ValueSource must be of source_type='http'."
|
||||
),
|
||||
)
|
||||
operator: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Comparison operator for http_poll rule: "
|
||||
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
|
||||
),
|
||||
)
|
||||
value: str | None = Field(
|
||||
None, description="Expected value (for http_poll rule; ignored for 'exists')"
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatible alias
|
||||
@@ -59,31 +73,49 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AutomationResponse(BaseModel):
|
||||
@@ -94,20 +126,30 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -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,19 +15,39 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateResponse(BaseModel):
|
||||
@@ -39,7 +59,17 @@ 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")
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateListResponse(BaseModel):
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -28,7 +27,7 @@ class AnimationConfig(BaseModel):
|
||||
"""Procedural animation configuration for static/gradient color strip sources."""
|
||||
|
||||
enabled: bool = True
|
||||
type: str = "breathing" # breathing | color_cycle | gradient_shift | wave
|
||||
type: str = "breathing" # breathing | gradient_shift | wave
|
||||
speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1-10.0)")
|
||||
|
||||
|
||||
@@ -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,15 +85,25 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PictureCSSResponse(_CSSResponseBase):
|
||||
@@ -102,45 +111,40 @@ class PictureCSSResponse(_CSSResponseBase):
|
||||
picture_source_id: str = Field(description="Picture source ID")
|
||||
smoothing: Any = Field(description="Temporal smoothing")
|
||||
interpolation_mode: str = Field(description="Interpolation mode")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
calibration: Calibration | None = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class PictureAdvancedCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["picture_advanced"] = "picture_advanced"
|
||||
smoothing: Any = Field(description="Temporal smoothing")
|
||||
interpolation_mode: str = Field(description="Interpolation mode")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
calibration: Calibration | None = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class StaticCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
color: Any = Field(description="Static RGB color")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
class SingleColorCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["single_color"] = "single_color"
|
||||
color: Any = Field(description="Solid RGB color")
|
||||
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
|
||||
|
||||
|
||||
class GradientCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["gradient"] = "gradient"
|
||||
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
stops: List[ColorStop] | None = Field(None, description="Color stops")
|
||||
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
|
||||
easing: str = Field(description="Gradient interpolation easing")
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
||||
|
||||
|
||||
class ColorCycleCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["color_cycle"] = "color_cycle"
|
||||
colors: List[List[int]] = Field(description="List of [R,G,B] colors to cycle")
|
||||
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):
|
||||
@@ -160,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")
|
||||
@@ -183,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")
|
||||
|
||||
@@ -232,29 +236,34 @@ class MathWaveCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["math_wave"] = "math_wave"
|
||||
waves: List[dict] = Field(description="Wave layer definitions")
|
||||
speed: Any = Field(description="Global speed multiplier (bindable)")
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
|
||||
gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
|
||||
|
||||
|
||||
class GameEventCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["game_event"] = "game_event"
|
||||
game_integration_id: str = Field(description="Game integration entity ID")
|
||||
idle_color: Any = Field(description="Idle RGB color (bindable)")
|
||||
event_mappings: List[dict] = Field(default_factory=list, description="Event-to-effect mappings")
|
||||
|
||||
|
||||
ColorStripSourceResponse = Annotated[
|
||||
Union[
|
||||
Annotated[PictureCSSResponse, Tag("picture")],
|
||||
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSResponse, Tag("static")],
|
||||
Annotated[GradientCSSResponse, Tag("gradient")],
|
||||
Annotated[ColorCycleCSSResponse, Tag("color_cycle")],
|
||||
Annotated[EffectCSSResponse, Tag("effect")],
|
||||
Annotated[CompositeCSSResponse, Tag("composite")],
|
||||
Annotated[MappedCSSResponse, Tag("mapped")],
|
||||
Annotated[AudioCSSResponse, Tag("audio")],
|
||||
Annotated[ApiInputCSSResponse, Tag("api_input")],
|
||||
Annotated[NotificationCSSResponse, Tag("notification")],
|
||||
Annotated[DaylightCSSResponse, Tag("daylight")],
|
||||
Annotated[CandlelightCSSResponse, Tag("candlelight")],
|
||||
Annotated[ProcessedCSSResponse, Tag("processed")],
|
||||
Annotated[WeatherCSSResponse, Tag("weather")],
|
||||
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
|
||||
Annotated[MathWaveCSSResponse, Tag("math_wave")],
|
||||
],
|
||||
Annotated[PictureCSSResponse, Tag("picture")]
|
||||
| Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")]
|
||||
| Annotated[SingleColorCSSResponse, Tag("single_color")]
|
||||
| Annotated[GradientCSSResponse, Tag("gradient")]
|
||||
| Annotated[EffectCSSResponse, Tag("effect")]
|
||||
| Annotated[CompositeCSSResponse, Tag("composite")]
|
||||
| Annotated[MappedCSSResponse, Tag("mapped")]
|
||||
| Annotated[AudioCSSResponse, Tag("audio")]
|
||||
| Annotated[ApiInputCSSResponse, Tag("api_input")]
|
||||
| Annotated[NotificationCSSResponse, Tag("notification")]
|
||||
| Annotated[DaylightCSSResponse, Tag("daylight")]
|
||||
| Annotated[CandlelightCSSResponse, Tag("candlelight")]
|
||||
| Annotated[ProcessedCSSResponse, Tag("processed")]
|
||||
| Annotated[WeatherCSSResponse, Tag("weather")]
|
||||
| Annotated[KeyColorsCSSResponse, Tag("key_colors")]
|
||||
| Annotated[MathWaveCSSResponse, Tag("math_wave")]
|
||||
| Annotated[GameEventCSSResponse, Tag("game_event")],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
@@ -269,9 +278,19 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PictureCSSCreate(_CSSCreateBase):
|
||||
@@ -279,68 +298,63 @@ class PictureCSSCreate(_CSSCreateBase):
|
||||
picture_source_id: str = Field(default="", description="Picture source ID")
|
||||
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
|
||||
interpolation_mode: str = Field(default="average", description="Interpolation mode")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
calibration: Calibration | None = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class PictureAdvancedCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["picture_advanced"] = "picture_advanced"
|
||||
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
|
||||
interpolation_mode: str = Field(default="average", description="Interpolation mode")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
calibration: Calibration | None = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class StaticCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
color: Any = Field(default=None, description="Static RGB color [R,G,B]")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
class SingleColorCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["single_color"] = "single_color"
|
||||
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
|
||||
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
|
||||
|
||||
|
||||
class GradientCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["gradient"] = "gradient"
|
||||
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
easing: Optional[str] = Field(None, description="Gradient easing")
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
||||
|
||||
|
||||
class ColorCycleCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["color_cycle"] = "color_cycle"
|
||||
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
|
||||
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)"
|
||||
)
|
||||
@@ -350,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"
|
||||
)
|
||||
|
||||
@@ -374,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
|
||||
)
|
||||
|
||||
@@ -385,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)")
|
||||
|
||||
@@ -409,42 +423,47 @@ class WeatherCSSCreate(_CSSCreateBase):
|
||||
class KeyColorsCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["key_colors"] = "key_colors"
|
||||
picture_source_id: str = Field(default="", description="Picture source ID")
|
||||
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions")
|
||||
rectangles: List[dict] | None = Field(None, description="Named screen regions")
|
||||
interpolation_mode: str = Field(default="average", description="Interpolation mode")
|
||||
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
|
||||
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
|
||||
brightness_value_source_id: Optional[str] = Field(
|
||||
brightness_value_source_id: str | None = Field(
|
||||
None, description="Dynamic brightness value source ID"
|
||||
)
|
||||
|
||||
|
||||
class MathWaveCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["math_wave"] = "math_wave"
|
||||
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions")
|
||||
waves: List[dict] | None = Field(None, description="Wave layer definitions")
|
||||
speed: Any = Field(default=None, description="Global speed multiplier (bindable, 0.1-10.0)")
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
|
||||
gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
|
||||
|
||||
|
||||
class GameEventCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["game_event"] = "game_event"
|
||||
game_integration_id: str | None = Field(None, description="Game integration entity ID")
|
||||
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
|
||||
event_mappings: List[dict] | None = Field(None, description="Event-to-effect mappings")
|
||||
|
||||
|
||||
ColorStripSourceCreate = Annotated[
|
||||
Union[
|
||||
Annotated[PictureCSSCreate, Tag("picture")],
|
||||
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSCreate, Tag("static")],
|
||||
Annotated[GradientCSSCreate, Tag("gradient")],
|
||||
Annotated[ColorCycleCSSCreate, Tag("color_cycle")],
|
||||
Annotated[EffectCSSCreate, Tag("effect")],
|
||||
Annotated[CompositeCSSCreate, Tag("composite")],
|
||||
Annotated[MappedCSSCreate, Tag("mapped")],
|
||||
Annotated[AudioCSSCreate, Tag("audio")],
|
||||
Annotated[ApiInputCSSCreate, Tag("api_input")],
|
||||
Annotated[NotificationCSSCreate, Tag("notification")],
|
||||
Annotated[DaylightCSSCreate, Tag("daylight")],
|
||||
Annotated[CandlelightCSSCreate, Tag("candlelight")],
|
||||
Annotated[ProcessedCSSCreate, Tag("processed")],
|
||||
Annotated[WeatherCSSCreate, Tag("weather")],
|
||||
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
|
||||
Annotated[MathWaveCSSCreate, Tag("math_wave")],
|
||||
],
|
||||
Annotated[PictureCSSCreate, Tag("picture")]
|
||||
| Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")]
|
||||
| Annotated[SingleColorCSSCreate, Tag("single_color")]
|
||||
| Annotated[GradientCSSCreate, Tag("gradient")]
|
||||
| Annotated[EffectCSSCreate, Tag("effect")]
|
||||
| Annotated[CompositeCSSCreate, Tag("composite")]
|
||||
| Annotated[MappedCSSCreate, Tag("mapped")]
|
||||
| Annotated[AudioCSSCreate, Tag("audio")]
|
||||
| Annotated[ApiInputCSSCreate, Tag("api_input")]
|
||||
| Annotated[NotificationCSSCreate, Tag("notification")]
|
||||
| Annotated[DaylightCSSCreate, Tag("daylight")]
|
||||
| Annotated[CandlelightCSSCreate, Tag("candlelight")]
|
||||
| Annotated[ProcessedCSSCreate, Tag("processed")]
|
||||
| Annotated[WeatherCSSCreate, Tag("weather")]
|
||||
| Annotated[KeyColorsCSSCreate, Tag("key_colors")]
|
||||
| Annotated[MathWaveCSSCreate, Tag("math_wave")]
|
||||
| Annotated[GameEventCSSCreate, Tag("game_event")],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
@@ -457,80 +476,85 @@ 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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PictureCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["picture"] = "picture"
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
picture_source_id: str | None = Field(None, description="Picture source ID")
|
||||
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
|
||||
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
interpolation_mode: str | None = Field(None, description="Interpolation mode")
|
||||
calibration: Calibration | None = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class PictureAdvancedCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["picture_advanced"] = "picture_advanced"
|
||||
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
|
||||
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
interpolation_mode: str | None = Field(None, description="Interpolation mode")
|
||||
calibration: Calibration | None = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class StaticCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
color: Any = Field(default=None, description="Static RGB color [R,G,B]")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
class SingleColorCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["single_color"] = "single_color"
|
||||
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
|
||||
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
|
||||
|
||||
|
||||
class GradientCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["gradient"] = "gradient"
|
||||
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
easing: Optional[str] = Field(None, description="Gradient easing")
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
||||
|
||||
|
||||
class ColorCycleCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["color_cycle"] = "color_cycle"
|
||||
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
|
||||
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)")
|
||||
|
||||
|
||||
@@ -538,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"
|
||||
)
|
||||
|
||||
@@ -562,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
|
||||
)
|
||||
|
||||
@@ -573,66 +597,71 @@ class CandlelightCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["candlelight"] = "candlelight"
|
||||
color: Any = Field(default=None, description="Candle color [R,G,B]")
|
||||
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
|
||||
num_candles: Optional[int] = Field(
|
||||
num_candles: int | None = Field(
|
||||
None, description="Number of candle sources (1-20)", ge=1, le=20
|
||||
)
|
||||
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
|
||||
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
|
||||
candle_type: Optional[str] = Field(None, description="Candle type preset")
|
||||
candle_type: str | None = Field(None, description="Candle type preset")
|
||||
|
||||
|
||||
class ProcessedCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["processed"] = "processed"
|
||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
|
||||
processing_template_id: Optional[str] = Field(None, description="Processing template ID")
|
||||
input_source_id: str | None = Field(None, description="Input color strip source ID")
|
||||
processing_template_id: str | None = Field(None, description="Processing template ID")
|
||||
|
||||
|
||||
class WeatherCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["weather"] = "weather"
|
||||
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
|
||||
weather_source_id: str | None = Field(None, description="Weather source entity ID")
|
||||
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
|
||||
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
|
||||
|
||||
|
||||
class KeyColorsCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["key_colors"] = "key_colors"
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions")
|
||||
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
|
||||
picture_source_id: str | None = Field(None, description="Picture source ID")
|
||||
rectangles: List[dict] | None = Field(None, description="Named screen regions")
|
||||
interpolation_mode: str | None = Field(None, description="Interpolation mode")
|
||||
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
|
||||
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
|
||||
brightness_value_source_id: Optional[str] = Field(
|
||||
brightness_value_source_id: str | None = Field(
|
||||
None, description="Dynamic brightness value source ID"
|
||||
)
|
||||
|
||||
|
||||
class MathWaveCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["math_wave"] = "math_wave"
|
||||
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions")
|
||||
waves: List[dict] | None = Field(None, description="Wave layer definitions")
|
||||
speed: Any = Field(default=None, description="Global speed multiplier (bindable)")
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
|
||||
gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
|
||||
|
||||
|
||||
class GameEventCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["game_event"] = "game_event"
|
||||
game_integration_id: str | None = Field(None, description="Game integration entity ID")
|
||||
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
|
||||
event_mappings: List[dict] | None = Field(None, description="Event-to-effect mappings")
|
||||
|
||||
|
||||
ColorStripSourceUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[PictureCSSUpdate, Tag("picture")],
|
||||
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSUpdate, Tag("static")],
|
||||
Annotated[GradientCSSUpdate, Tag("gradient")],
|
||||
Annotated[ColorCycleCSSUpdate, Tag("color_cycle")],
|
||||
Annotated[EffectCSSUpdate, Tag("effect")],
|
||||
Annotated[CompositeCSSUpdate, Tag("composite")],
|
||||
Annotated[MappedCSSUpdate, Tag("mapped")],
|
||||
Annotated[AudioCSSUpdate, Tag("audio")],
|
||||
Annotated[ApiInputCSSUpdate, Tag("api_input")],
|
||||
Annotated[NotificationCSSUpdate, Tag("notification")],
|
||||
Annotated[DaylightCSSUpdate, Tag("daylight")],
|
||||
Annotated[CandlelightCSSUpdate, Tag("candlelight")],
|
||||
Annotated[ProcessedCSSUpdate, Tag("processed")],
|
||||
Annotated[WeatherCSSUpdate, Tag("weather")],
|
||||
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
|
||||
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
|
||||
],
|
||||
Annotated[PictureCSSUpdate, Tag("picture")]
|
||||
| Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")]
|
||||
| Annotated[SingleColorCSSUpdate, Tag("single_color")]
|
||||
| Annotated[GradientCSSUpdate, Tag("gradient")]
|
||||
| Annotated[EffectCSSUpdate, Tag("effect")]
|
||||
| Annotated[CompositeCSSUpdate, Tag("composite")]
|
||||
| Annotated[MappedCSSUpdate, Tag("mapped")]
|
||||
| Annotated[AudioCSSUpdate, Tag("audio")]
|
||||
| Annotated[ApiInputCSSUpdate, Tag("api_input")]
|
||||
| Annotated[NotificationCSSUpdate, Tag("notification")]
|
||||
| Annotated[DaylightCSSUpdate, Tag("daylight")]
|
||||
| Annotated[CandlelightCSSUpdate, Tag("candlelight")]
|
||||
| Annotated[ProcessedCSSUpdate, Tag("processed")]
|
||||
| Annotated[WeatherCSSUpdate, Tag("weather")]
|
||||
| Annotated[KeyColorsCSSUpdate, Tag("key_colors")]
|
||||
| Annotated[MathWaveCSSUpdate, Tag("math_wave")]
|
||||
| Annotated[GameEventCSSUpdate, Tag("game_event")],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
@@ -655,13 +684,25 @@ class ColorStripSourceListResponse(BaseModel):
|
||||
|
||||
|
||||
class SegmentPayload(BaseModel):
|
||||
"""A single segment for segment-based LED color updates."""
|
||||
"""A single segment for segment-based LED color updates.
|
||||
|
||||
start: int = Field(ge=0, description="Starting LED index")
|
||||
length: int = Field(ge=1, description="Number of LEDs in segment")
|
||||
``start`` and ``length`` are optional: when omitted, the segment defaults
|
||||
to ``start=0`` and ``length=led_count - start`` (i.e. the rest of the
|
||||
strip from ``start``). Sending a single segment with only ``mode`` and
|
||||
``color`` therefore fills the entire strip.
|
||||
"""
|
||||
|
||||
start: int | None = Field(
|
||||
None, ge=0, description="Starting LED index (default 0 = beginning of strip)"
|
||||
)
|
||||
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],...]"
|
||||
)
|
||||
|
||||
@@ -694,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":
|
||||
@@ -711,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,136 +10,258 @@ 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: int | None = Field(
|
||||
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
|
||||
)
|
||||
ddp_destination_id: int | None = Field(
|
||||
None, ge=0, le=255, description="DDP destination ID (default 1 = display)"
|
||||
)
|
||||
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: 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: 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: 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: int | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=10000,
|
||||
description="Govee client-side rate limit between commands in ms (default 50)",
|
||||
)
|
||||
# OPC fields
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=512,
|
||||
description="Nanoleaf auth token returned by the pairing handshake",
|
||||
)
|
||||
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: 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: 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.",
|
||||
)
|
||||
|
||||
|
||||
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)"
|
||||
)
|
||||
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"
|
||||
ddp_port: int | None = Field(
|
||||
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
|
||||
)
|
||||
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(
|
||||
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: int | None = Field(
|
||||
None, ge=0, le=10000, description="WiZ client-side rate limit in ms"
|
||||
)
|
||||
lifx_min_interval_ms: int | None = Field(
|
||||
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
|
||||
)
|
||||
govee_min_interval_ms: int | None = Field(
|
||||
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
|
||||
)
|
||||
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: 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
class PairDeviceRequest(BaseModel):
|
||||
"""Initiate a pairing handshake with a device before creating it.
|
||||
|
||||
The caller is expected to have just performed the device's physical
|
||||
pairing action (e.g. holding the power button on a Nanoleaf for 5 s,
|
||||
pressing the Hue bridge link button). The response carries any
|
||||
provider-specific fields the frontend must include in the subsequent
|
||||
``POST /api/v1/devices`` payload — typically an auth token.
|
||||
"""
|
||||
|
||||
device_type: str = Field(description="Device type identifier (e.g. 'nanoleaf')")
|
||||
url: str = Field(description="Device URL (e.g. 'nanoleaf://192.168.1.50')")
|
||||
|
||||
|
||||
class PairDeviceResponse(BaseModel):
|
||||
"""Successful pairing result. ``fields`` is merged into the create payload."""
|
||||
|
||||
fields: Dict[str, object] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"Provider-specific fields to include in the subsequent device-create "
|
||||
"request (e.g. {'nanoleaf_token': 'abc...'})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CalibrationLineSchema(BaseModel):
|
||||
@@ -162,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
|
||||
@@ -256,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"
|
||||
)
|
||||
@@ -272,11 +394,38 @@ class DeviceResponse(BaseModel):
|
||||
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
|
||||
dmx_start_universe: int = Field(default=0, description="DMX start universe")
|
||||
dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)")
|
||||
ddp_port: int = Field(default=0, description="DDP UDP port (0 = protocol default 4048)")
|
||||
ddp_destination_id: int = Field(default=1, description="DDP destination ID")
|
||||
ddp_color_order: int = Field(default=1, description="DDP color order code (1 = RGB)")
|
||||
espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address")
|
||||
espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel")
|
||||
hue_username: str = Field(default="", description="Hue bridge username")
|
||||
hue_client_key: str = Field(default="", description="Hue entertainment client key")
|
||||
hue_paired: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Whether the Hue bridge has been paired (i.e. a username/client_key "
|
||||
"is on file). The actual credentials are intentionally not exposed "
|
||||
"in the response -- to re-pair, delete and re-add the device."
|
||||
),
|
||||
)
|
||||
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
|
||||
yeelight_min_interval_ms: int = Field(
|
||||
default=500, description="Yeelight client-side rate limit in ms"
|
||||
)
|
||||
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
|
||||
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
|
||||
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
|
||||
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
|
||||
nanoleaf_paired: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Whether the Nanoleaf auth token has been issued by the pairing "
|
||||
"handshake. The token itself is intentionally not exposed in the "
|
||||
"response -- to re-pair, delete and re-add the device."
|
||||
),
|
||||
)
|
||||
nanoleaf_min_interval_ms: int = Field(
|
||||
default=100, description="Nanoleaf client-side rate limit in ms"
|
||||
)
|
||||
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
|
||||
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||
@@ -295,6 +444,8 @@ class DeviceResponse(BaseModel):
|
||||
default_factory=list, description="Ordered list of child device IDs (for group device type)"
|
||||
)
|
||||
group_mode: str = Field(default="sequence", description="Group mode: sequence or independent")
|
||||
icon: str = Field(default="", description="Icon id from the curated icon library")
|
||||
icon_color: str = Field(default="", description="Optional CSS color override for the icon")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
@@ -312,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"
|
||||
@@ -339,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,22 +39,42 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class GameIntegrationResponse(BaseModel):
|
||||
@@ -69,8 +88,18 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon.",
|
||||
)
|
||||
|
||||
|
||||
class GameIntegrationListResponse(BaseModel):
|
||||
@@ -128,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,17 +18,37 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class GradientResponse(BaseModel):
|
||||
@@ -38,10 +58,20 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class GradientListResponse(BaseModel):
|
||||
|
||||
@@ -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,20 +16,40 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantSourceResponse(BaseModel):
|
||||
@@ -42,11 +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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
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 "
|
||||
@@ -82,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):
|
||||
@@ -94,6 +124,7 @@ class HomeAssistantConnectionStatus(BaseModel):
|
||||
name: str
|
||||
connected: bool
|
||||
entity_count: int
|
||||
host: str = ""
|
||||
|
||||
|
||||
class HomeAssistantStatusResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""HTTP endpoint schemas (CRUD + one-shot test)."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# RFC 7230 token chars for header names + reject any control character in values.
|
||||
_HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
|
||||
_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def _validate_headers(value: Dict[str, str]) -> Dict[str, str]:
|
||||
"""Reject header names/values that could enable CRLF injection."""
|
||||
if value is None:
|
||||
return {}
|
||||
cleaned: Dict[str, str] = {}
|
||||
for name, raw in value.items():
|
||||
if not isinstance(name, str) or not isinstance(raw, str):
|
||||
raise ValueError(f"Header name/value must be strings: {name!r}")
|
||||
if not _HEADER_NAME_RE.match(name):
|
||||
raise ValueError(f"Invalid HTTP header name: {name!r}")
|
||||
if _HEADER_CONTROL_CHARS_RE.search(raw):
|
||||
raise ValueError(
|
||||
f"Invalid HTTP header value for {name!r} (contains control characters)"
|
||||
)
|
||||
cleaned[name] = raw
|
||||
return cleaned
|
||||
|
||||
|
||||
def _validate_url(value: str) -> str:
|
||||
"""Reject URLs that embed ``user:pass@`` so credentials can't leak
|
||||
into server logs (e.g. via the plaintext-token warning helper).
|
||||
|
||||
Schemes + IP-block checks are enforced later by
|
||||
:func:`ledgrab.utils.safe_source.validate_polling_url`.
|
||||
"""
|
||||
if not value:
|
||||
return value
|
||||
parsed = urlparse(value)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError(
|
||||
"URL must not embed credentials (user:pass@). "
|
||||
"Use the auth_token field or a custom Authorization header instead."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class HTTPEndpointCreate(BaseModel):
|
||||
"""Request to create an HTTP endpoint."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
url: str = Field(min_length=1, description="http or https URL")
|
||||
method: Literal["GET", "HEAD"] = "GET"
|
||||
auth_token: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Optional bearer token — sent as 'Authorization: Bearer <token>'. "
|
||||
"Add a custom Authorization entry in `headers` to override."
|
||||
),
|
||||
)
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float = Field(default=10.0, gt=0)
|
||||
description: str | None = Field(None, max_length=500)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
icon: str | None = Field(None, max_length=64)
|
||||
icon_color: str | None = Field(None, max_length=32)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPEndpointUpdate(BaseModel):
|
||||
"""Request to update an HTTP endpoint.
|
||||
|
||||
All fields optional — ``None`` keeps the existing value. Sending an
|
||||
empty string for ``auth_token`` CLEARS the stored token; omit the
|
||||
field (or send ``null``) to keep it.
|
||||
"""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
url: str | None = Field(None, min_length=1)
|
||||
method: Literal["GET", "HEAD"] | None = None
|
||||
auth_token: str | None = Field(None, description="null = keep existing; '' = clear.")
|
||||
headers: Dict[str, str] | None = None
|
||||
timeout_s: float | None = Field(None, gt=0)
|
||||
description: str | None = Field(None, max_length=500)
|
||||
tags: List[str] | None = None
|
||||
icon: str | None = Field(None, max_length=64)
|
||||
icon_color: str | None = Field(None, max_length=32)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPEndpointResponse(BaseModel):
|
||||
"""HTTP endpoint response. Note: ``auth_token`` is NEVER returned —
|
||||
use ``auth_token_set`` to know whether one is configured."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
method: str
|
||||
auth_token_set: bool = False
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float
|
||||
description: str | None = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
icon: str | None = Field(None, max_length=64)
|
||||
icon_color: str | None = Field(None, max_length=32)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HTTPEndpointListResponse(BaseModel):
|
||||
endpoints: List[HTTPEndpointResponse]
|
||||
count: int
|
||||
|
||||
|
||||
class HTTPTestRequest(BaseModel):
|
||||
"""One-shot test request to validate URL + auth before saving."""
|
||||
|
||||
url: str
|
||||
method: Literal["GET", "HEAD"] = "GET"
|
||||
auth_token: str = ""
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float = Field(default=10.0, gt=0)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPTestResponse(BaseModel):
|
||||
success: bool
|
||||
status_code: int | None = None
|
||||
body_preview: str | None = Field(None, description="First 500 chars of the body")
|
||||
body_json: Any = None
|
||||
error: str | None = None
|
||||
@@ -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,22 +16,42 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class MQTTSourceResponse(BaseModel):
|
||||
@@ -46,8 +66,18 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -63,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,21 @@ 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)"
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightMappingSchema(BaseModel):
|
||||
"""Maps an LED range to one Zigbee2MQTT bulb (by friendly name)."""
|
||||
|
||||
friendly_name: str = Field(
|
||||
description="Z2M friendly_name (e.g. 'living_room_bulb_1')",
|
||||
min_length=1,
|
||||
)
|
||||
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
|
||||
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
|
||||
brightness_scale: BindableFloatInput | None = Field(
|
||||
default=1.0, description="Brightness multiplier (bindable)"
|
||||
)
|
||||
|
||||
@@ -53,8 +67,10 @@ 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")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
@@ -63,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(
|
||||
@@ -81,30 +97,91 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
|
||||
class HALightOutputTargetResponse(_OutputTargetResponseBase):
|
||||
target_type: Literal["ha_light"] = "ha_light"
|
||||
ha_source_id: str = Field(default="", description="Home Assistant source ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
source_kind: Literal["css", "color_vs"] = Field(
|
||||
default="css",
|
||||
description="Colour source kind: 'css' (per-mapping LED segments) or "
|
||||
"'color_vs' (single colour value source applied to all entities).",
|
||||
)
|
||||
color_strip_source_id: str = Field(
|
||||
default="", description="Color strip source ID (used when source_kind='css')"
|
||||
)
|
||||
color_value_source_id: str = Field(
|
||||
default="",
|
||||
description="Colour value source ID (used when source_kind='color_vs'); "
|
||||
"must reference a value source whose return_type='color'.",
|
||||
)
|
||||
brightness: 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(
|
||||
default="none",
|
||||
description="What to do with mapped lights when the target stops: "
|
||||
"'none' (leave as-is), 'turn_off', or 'restore' (revert to state captured at start).",
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
|
||||
target_type: Literal["z2m_light"] = "z2m_light"
|
||||
mqtt_source_id: str = Field(
|
||||
default="",
|
||||
description="MQTT source (broker) the target publishes to. Empty = unconfigured.",
|
||||
)
|
||||
source_kind: Literal["css", "color_vs"] = Field(
|
||||
default="css",
|
||||
description="Colour source kind: 'css' (per-mapping LED segments) or "
|
||||
"'color_vs' (single colour value source applied to all bulbs).",
|
||||
)
|
||||
color_strip_source_id: str = Field(
|
||||
default="", description="Color strip source ID (used when source_kind='css')"
|
||||
)
|
||||
color_value_source_id: str = Field(
|
||||
default="",
|
||||
description="Colour value source ID (used when source_kind='color_vs').",
|
||||
)
|
||||
brightness: 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: BindableFloatInput | None = Field(
|
||||
None, description="Publish rate Hz (bindable; 0.5-10)"
|
||||
)
|
||||
transition: BindableFloatInput | None = Field(
|
||||
None, description="Z2M transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: BindableFloatInput | None = Field(
|
||||
None, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: BindableFloatInput | None = Field(
|
||||
default=0, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
stop_action: Literal["none", "turn_off"] = Field(
|
||||
default="none",
|
||||
description="What to do with mapped bulbs when the target stops: "
|
||||
"'none' (leave as-is) or 'turn_off'.",
|
||||
)
|
||||
|
||||
|
||||
OutputTargetResponse = Annotated[
|
||||
Union[
|
||||
Annotated[LedOutputTargetResponse, Tag("led")],
|
||||
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
|
||||
],
|
||||
Annotated[LedOutputTargetResponse, Tag("led")]
|
||||
| Annotated[HALightOutputTargetResponse, Tag("ha_light")]
|
||||
| Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
|
||||
@@ -117,18 +194,22 @@ 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: str | None = Field(
|
||||
None, max_length=64, description="Custom icon id from the curated icon library"
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None, max_length=32, description="Optional CSS color override for the icon"
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
@@ -143,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",
|
||||
)
|
||||
@@ -160,33 +241,90 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
|
||||
class HALightOutputTargetCreate(_OutputTargetCreateBase):
|
||||
target_type: Literal["ha_light"] = "ha_light"
|
||||
ha_source_id: str = Field(default="", description="Home Assistant source ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
brightness: Optional[BindableFloatInput] = Field(
|
||||
default=1.0, description="Brightness (bindable)"
|
||||
source_kind: Literal["css", "color_vs"] = Field(
|
||||
default="css",
|
||||
description="Colour source kind: 'css' (per-mapping LED segments) or "
|
||||
"'color_vs' (single colour value source applied to all entities).",
|
||||
)
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
color_strip_source_id: str = Field(
|
||||
default="", description="Color strip source ID (used when source_kind='css')"
|
||||
)
|
||||
color_value_source_id: str = Field(
|
||||
default="",
|
||||
description="Colour value source ID (used when source_kind='color_vs').",
|
||||
)
|
||||
brightness: 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",
|
||||
)
|
||||
stop_action: Literal["none", "turn_off", "restore"] = Field(
|
||||
default="none",
|
||||
description="Finalization on stop: 'none', 'turn_off', or 'restore'.",
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
|
||||
target_type: Literal["z2m_light"] = "z2m_light"
|
||||
mqtt_source_id: str = Field(
|
||||
default="",
|
||||
description="MQTT source (broker) the target publishes to. Required to start.",
|
||||
)
|
||||
source_kind: Literal["css", "color_vs"] = Field(
|
||||
default="css",
|
||||
description="Colour source kind: 'css' or 'color_vs'.",
|
||||
)
|
||||
color_strip_source_id: str = Field(
|
||||
default="", description="Color strip source ID (used when source_kind='css')"
|
||||
)
|
||||
color_value_source_id: str = Field(
|
||||
default="",
|
||||
description="Colour value source ID (used when source_kind='color_vs').",
|
||||
)
|
||||
brightness: 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(
|
||||
default="zigbee2mqtt",
|
||||
max_length=128,
|
||||
description="Z2M MQTT base topic prefix.",
|
||||
)
|
||||
update_rate: BindableFloatInput | None = Field(
|
||||
default=5.0, description="Publish rate in Hz (bindable; 0.5-10)"
|
||||
)
|
||||
transition: BindableFloatInput | None = Field(
|
||||
default=0.3, description="Z2M transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: BindableFloatInput | None = Field(
|
||||
default=5, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: BindableFloatInput | None = Field(
|
||||
default=0,
|
||||
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
|
||||
)
|
||||
stop_action: Literal["none", "turn_off"] = Field(
|
||||
default="none",
|
||||
description="Finalization on stop: 'none' or 'turn_off'.",
|
||||
)
|
||||
|
||||
|
||||
OutputTargetCreate = Annotated[
|
||||
Union[
|
||||
Annotated[LedOutputTargetCreate, Tag("led")],
|
||||
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
|
||||
],
|
||||
Annotated[LedOutputTargetCreate, Tag("led")]
|
||||
| Annotated[HALightOutputTargetCreate, Tag("ha_light")]
|
||||
| Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
|
||||
@@ -198,61 +336,116 @@ 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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon; empty string clears.",
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = 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: 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: 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: 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: str | None = Field(
|
||||
None,
|
||||
description="MQTT source (broker) id. Empty string clears the binding.",
|
||||
)
|
||||
source_kind: Literal["css", "color_vs"] | None = Field(
|
||||
None, description="Colour source kind: 'css' or 'color_vs'."
|
||||
)
|
||||
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: 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 | 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: BindableFloatInput | None = Field(
|
||||
None, description="Z2M transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: BindableFloatInput | None = Field(
|
||||
None, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: BindableFloatInput | None = Field(
|
||||
None, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
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[LedOutputTargetUpdate, Tag("led")]
|
||||
| Annotated[HALightOutputTargetUpdate, Tag("ha_light")]
|
||||
| Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
|
||||
@@ -272,72 +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")
|
||||
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_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"
|
||||
)
|
||||
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,19 +15,39 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PatternTemplateResponse(BaseModel):
|
||||
@@ -39,7 +59,17 @@ 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")
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PatternTemplateListResponse(BaseModel):
|
||||
|
||||
@@ -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,10 +15,20 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class RawPictureSourceResponse(_PictureSourceResponseBase):
|
||||
@@ -36,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"),
|
||||
]
|
||||
|
||||
@@ -70,8 +78,18 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class RawPictureSourceCreate(_PictureSourceCreateBase):
|
||||
@@ -97,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"),
|
||||
]
|
||||
|
||||
@@ -124,54 +140,60 @@ 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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -216,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,19 +15,39 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PostprocessingTemplateResponse(BaseModel):
|
||||
@@ -39,7 +59,17 @@ 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")
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PostprocessingTemplateListResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""User-preference schemas (notifications, future per-user settings)."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
NotificationChannel = Literal["none", "snack", "os", "both"]
|
||||
|
||||
|
||||
class NotificationChannelMatrix(BaseModel):
|
||||
"""Channel selection per device-event type."""
|
||||
|
||||
device_online: NotificationChannel = Field(
|
||||
default="snack",
|
||||
description="Configured device transitioned from offline to online",
|
||||
)
|
||||
device_offline: NotificationChannel = Field(
|
||||
default="both",
|
||||
description="Configured device went offline (urgent — likely user wants OS toast)",
|
||||
)
|
||||
device_discovered: NotificationChannel = Field(
|
||||
default="snack",
|
||||
description="A new WLED/serial device appeared on the LAN/USB",
|
||||
)
|
||||
device_lost: NotificationChannel = Field(
|
||||
default="none",
|
||||
description=(
|
||||
"Previously discovered (but never configured) device disappeared. "
|
||||
"Default off — usually noise unless the user is actively pairing."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class NotificationPreferences(BaseModel):
|
||||
"""User-level notification preferences."""
|
||||
|
||||
channels: NotificationChannelMatrix = Field(
|
||||
default_factory=NotificationChannelMatrix,
|
||||
description="Per-event-type channel selection",
|
||||
)
|
||||
background_discovery_enabled: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Run the continuous mDNS browser + serial-port poller while the server "
|
||||
"is up. Required for device_discovered/device_lost notifications. "
|
||||
"Disable to silence all discovery-driven events at the source."
|
||||
),
|
||||
)
|
||||
startup_grace_sec: int = Field(
|
||||
default=10,
|
||||
ge=0,
|
||||
le=300,
|
||||
description=(
|
||||
"Seconds after each event-WS connect during which device_offline "
|
||||
"notifications are suppressed (devices boot at different speeds)."
|
||||
),
|
||||
)
|
||||
flap_debounce_sec: int = Field(
|
||||
default=5,
|
||||
ge=0,
|
||||
le=60,
|
||||
description=(
|
||||
"A device must hold a new state for at least this many seconds before "
|
||||
"the corresponding notification is fired. Filters out single-packet drops."
|
||||
),
|
||||
)
|
||||
@@ -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,23 +19,41 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class ScenePresetResponse(BaseModel):
|
||||
@@ -47,6 +65,16 @@ class ScenePresetResponse(BaseModel):
|
||||
targets: List[TargetSnapshotSchema]
|
||||
order: int
|
||||
tags: List[str] = Field(default_factory=list, 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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -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,17 +11,37 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class SyncClockResponse(BaseModel):
|
||||
@@ -30,8 +50,18 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon.",
|
||||
)
|
||||
is_running: bool = Field(True, description="Whether clock is currently running")
|
||||
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
|
||||
@@ -26,6 +26,10 @@ class HealthResponse(BaseModel):
|
||||
)
|
||||
repo_url: str = Field(default="", description="Source code repository URL")
|
||||
donate_url: str = Field(default="", description="Donation page URL")
|
||||
uptime_seconds: float = Field(
|
||||
default=0.0,
|
||||
description="Process uptime in seconds since the server started.",
|
||||
)
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
@@ -98,6 +102,15 @@ class PerformanceResponse(BaseModel):
|
||||
default=None,
|
||||
description="Hottest CPU/SoC thermal zone in °C (null if unsupported)",
|
||||
)
|
||||
cpu_temp_hint_key: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"i18n key for an explainer shown in the Temperature card when "
|
||||
"cpu_temp_c is null and the platform has a known workaround "
|
||||
"(e.g. install LibreHardwareMonitor on Windows). Null on "
|
||||
"platforms where unavailable simply means 'not reported'."
|
||||
),
|
||||
)
|
||||
timestamp: datetime = Field(description="Measurement timestamp")
|
||||
|
||||
|
||||
@@ -191,6 +204,32 @@ class ExternalUrlRequest(BaseModel):
|
||||
external_url: str = Field(default="", description="External base URL. Empty string to clear.")
|
||||
|
||||
|
||||
# ─── Shutdown action schemas ───────────────────────────────────
|
||||
|
||||
|
||||
ShutdownAction = Literal["stop_targets", "nothing"]
|
||||
|
||||
|
||||
class ShutdownActionResponse(BaseModel):
|
||||
"""Current server shutdown action setting."""
|
||||
|
||||
action: ShutdownAction = Field(
|
||||
description=(
|
||||
"What happens to LED targets when the server shuts down. "
|
||||
"`stop_targets` runs the normal stop sequence (per-device "
|
||||
"auto_shutdown decides whether prior state is restored). "
|
||||
"`nothing` skips device-touching teardown — lights freeze on "
|
||||
"their last frame regardless of per-device auto_shutdown."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ShutdownActionRequest(BaseModel):
|
||||
"""Update the server shutdown action setting."""
|
||||
|
||||
action: ShutdownAction = Field(description="New shutdown action.")
|
||||
|
||||
|
||||
# ─── Log level schemas ─────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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,18 +12,38 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
@@ -36,7 +56,13 @@ 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")
|
||||
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: str | None = Field(
|
||||
None, max_length=32, description="Optional CSS color override for the icon."
|
||||
)
|
||||
|
||||
|
||||
class TemplateListResponse(BaseModel):
|
||||
@@ -52,6 +78,10 @@ class EngineInfo(BaseModel):
|
||||
type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')")
|
||||
name: str = Field(description="Human-readable engine name")
|
||||
default_config: Dict = Field(description="Default configuration for this engine")
|
||||
config_choices: Dict[str, List[str]] = Field(
|
||||
default_factory=dict,
|
||||
description="Allowed values for enum-like config keys on this platform",
|
||||
)
|
||||
available: bool = Field(description="Whether engine is available on this system")
|
||||
has_own_displays: bool = Field(
|
||||
default=False, description="Engine has its own device list (not desktop monitors)"
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UpdateAssetInfo(BaseModel):
|
||||
"""A downloadable asset attached to a release (e.g. an installer)."""
|
||||
|
||||
name: str
|
||||
size: int
|
||||
download_url: str
|
||||
|
||||
|
||||
class UpdateReleaseInfo(BaseModel):
|
||||
version: str
|
||||
tag: str
|
||||
@@ -10,6 +18,7 @@ class UpdateReleaseInfo(BaseModel):
|
||||
body: str
|
||||
prerelease: bool
|
||||
published_at: str
|
||||
assets: list[UpdateAssetInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateStatusResponse(BaseModel):
|
||||
|
||||
@@ -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,8 +15,18 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -73,6 +83,7 @@ class DaylightValueSourceResponse(_ValueSourceResponseBase):
|
||||
speed: float = Field(description="Simulation speed multiplier")
|
||||
use_real_time: bool = Field(description="Use wall-clock time")
|
||||
latitude: float = Field(description="Geographic latitude")
|
||||
longitude: float = Field(description="Geographic longitude")
|
||||
min_value: float = Field(description="Minimum output")
|
||||
max_value: float = Field(description="Maximum output")
|
||||
|
||||
@@ -87,8 +98,11 @@ class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["animated_color"] = "animated_color"
|
||||
return_type: Literal["color"] = "color"
|
||||
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
|
||||
speed: float = Field(description="Cycles per minute")
|
||||
easing: str = Field(description="Color easing: linear|step")
|
||||
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: str | None = Field(
|
||||
None, description="Optional sync clock ID for shared timing (overrides speed)"
|
||||
)
|
||||
|
||||
|
||||
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
|
||||
@@ -137,22 +151,32 @@ class SystemMetricsValueSourceResponse(_ValueSourceResponseBase):
|
||||
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
||||
|
||||
|
||||
class HTTPValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["http"] = "http"
|
||||
return_type: Literal["float"] = "float"
|
||||
http_endpoint_id: str = Field(description="HTTP endpoint ID")
|
||||
json_path: str = Field(description="Dot-path into the response body")
|
||||
interval_s: int = Field(description="Polling cadence (seconds)")
|
||||
min_value: float = Field(description="Raw value mapped to output 0.0")
|
||||
max_value: float = Field(description="Raw value mapped to output 1.0")
|
||||
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
||||
|
||||
|
||||
ValueSourceResponse = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceResponse, Tag("static")],
|
||||
Annotated[AnimatedValueSourceResponse, Tag("animated")],
|
||||
Annotated[AudioValueSourceResponse, Tag("audio")],
|
||||
Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")],
|
||||
Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")],
|
||||
Annotated[DaylightValueSourceResponse, Tag("daylight")],
|
||||
Annotated[StaticColorValueSourceResponse, Tag("static_color")],
|
||||
Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")],
|
||||
Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")],
|
||||
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")],
|
||||
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
|
||||
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")],
|
||||
],
|
||||
Annotated[StaticValueSourceResponse, Tag("static")]
|
||||
| Annotated[AnimatedValueSourceResponse, Tag("animated")]
|
||||
| Annotated[AudioValueSourceResponse, Tag("audio")]
|
||||
| Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")]
|
||||
| Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")]
|
||||
| Annotated[DaylightValueSourceResponse, Tag("daylight")]
|
||||
| Annotated[StaticColorValueSourceResponse, Tag("static_color")]
|
||||
| Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")]
|
||||
| Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")]
|
||||
| Annotated[HAEntityValueSourceResponse, Tag("ha_entity")]
|
||||
| Annotated[GradientMapValueSourceResponse, Tag("gradient_map")]
|
||||
| Annotated[CSSExtractValueSourceResponse, Tag("css_extract")]
|
||||
| Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")]
|
||||
| Annotated[HTTPValueSourceResponse, Tag("http")],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
@@ -165,8 +189,18 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
class StaticValueSourceCreate(_ValueSourceCreateBase):
|
||||
@@ -215,6 +249,9 @@ class DaylightValueSourceCreate(_ValueSourceCreateBase):
|
||||
speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0)
|
||||
use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation")
|
||||
latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||
longitude: float = Field(
|
||||
0.0, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0
|
||||
)
|
||||
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
@@ -234,7 +271,12 @@ class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
|
||||
description="Color list [[R,G,B], ...]",
|
||||
)
|
||||
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
|
||||
easing: str = Field("linear", description="Color easing: linear|step")
|
||||
easing: str = Field(
|
||||
"linear", description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
|
||||
)
|
||||
clock_id: str | None = Field(
|
||||
None, description="Optional sync clock ID (overrides speed when set)"
|
||||
)
|
||||
|
||||
|
||||
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
|
||||
@@ -278,22 +320,31 @@ class SystemMetricsValueSourceCreate(_ValueSourceCreateBase):
|
||||
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class HTTPValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["http"] = "http"
|
||||
http_endpoint_id: str = Field(description="HTTP endpoint ID")
|
||||
json_path: str = Field("", description="Dot-path into the response (empty = raw body text)")
|
||||
interval_s: int = Field(60, description="Polling cadence (seconds)", ge=1)
|
||||
min_value: float = Field(0.0, description="Raw value mapped to output 0.0")
|
||||
max_value: float = Field(100.0, description="Raw value mapped to output 1.0")
|
||||
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
ValueSourceCreate = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceCreate, Tag("static")],
|
||||
Annotated[AnimatedValueSourceCreate, Tag("animated")],
|
||||
Annotated[AudioValueSourceCreate, Tag("audio")],
|
||||
Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")],
|
||||
Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")],
|
||||
Annotated[DaylightValueSourceCreate, Tag("daylight")],
|
||||
Annotated[StaticColorValueSourceCreate, Tag("static_color")],
|
||||
Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")],
|
||||
Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")],
|
||||
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")],
|
||||
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
|
||||
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")],
|
||||
],
|
||||
Annotated[StaticValueSourceCreate, Tag("static")]
|
||||
| Annotated[AnimatedValueSourceCreate, Tag("animated")]
|
||||
| Annotated[AudioValueSourceCreate, Tag("audio")]
|
||||
| Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")]
|
||||
| Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")]
|
||||
| Annotated[DaylightValueSourceCreate, Tag("daylight")]
|
||||
| Annotated[StaticColorValueSourceCreate, Tag("static_color")]
|
||||
| Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")]
|
||||
| Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")]
|
||||
| Annotated[HAEntityValueSourceCreate, Tag("ha_entity")]
|
||||
| Annotated[GradientMapValueSourceCreate, Tag("gradient_map")]
|
||||
| Annotated[CSSExtractValueSourceCreate, Tag("css_extract")]
|
||||
| Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")]
|
||||
| Annotated[HTTPValueSourceCreate, Tag("http")],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
@@ -305,130 +356,155 @@ 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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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(None, description="Color easing: linear|step")
|
||||
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: str | None = Field(
|
||||
None, description="Optional sync clock ID (empty string clears, null leaves unchanged)"
|
||||
)
|
||||
|
||||
|
||||
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
|
||||
schedule: Optional[list] = Field(None, description="Color schedule")
|
||||
schedule: list | None = Field(None, description="Color schedule")
|
||||
|
||||
|
||||
class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["ha_entity"] = "ha_entity"
|
||||
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
|
||||
entity_id: Optional[str] = Field(None, description="HA entity ID")
|
||||
attribute: Optional[str] = Field(None, description="Attribute name")
|
||||
min_ha_value: Optional[float] = Field(None, description="Min HA value")
|
||||
max_ha_value: Optional[float] = Field(None, description="Max HA value")
|
||||
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||
ha_source_id: str | None = Field(None, description="Home Assistant source ID")
|
||||
entity_id: str | None = Field(None, description="HA entity ID")
|
||||
attribute: str | None = Field(None, description="Attribute name")
|
||||
min_ha_value: float | None = Field(None, description="Min HA value")
|
||||
max_ha_value: float | None = Field(None, description="Max HA value")
|
||||
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["gradient_map"] = "gradient_map"
|
||||
value_source_id: Optional[str] = Field(None, description="Input value source ID")
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
||||
easing: Optional[str] = Field(None, description="Interpolation mode")
|
||||
value_source_id: str | None = Field(None, description="Input value source ID")
|
||||
gradient_id: str | None = Field(None, description="Gradient entity ID")
|
||||
easing: str | None = Field(None, description="Interpolation mode")
|
||||
|
||||
|
||||
class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["css_extract"] = "css_extract"
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
led_start: Optional[int] = Field(None, description="LED range start", ge=0)
|
||||
led_end: Optional[int] = Field(None, description="LED range end")
|
||||
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
|
||||
led_start: int | None = Field(None, description="LED range start", ge=0)
|
||||
led_end: int | None = Field(None, description="LED range end")
|
||||
|
||||
|
||||
class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["system_metrics"] = "system_metrics"
|
||||
metric: Optional[str] = Field(None, description="System metric")
|
||||
min_value: Optional[float] = Field(None, description="Min value")
|
||||
max_value: Optional[float] = Field(None, description="Max value")
|
||||
max_rate: Optional[float] = Field(None, description="Max rate bytes/sec")
|
||||
disk_path: Optional[str] = Field(None, description="Disk path")
|
||||
sensor_label: Optional[str] = Field(None, description="Sensor label")
|
||||
poll_interval: Optional[float] = Field(None, description="Poll interval", ge=0.1, le=60.0)
|
||||
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||
metric: str | None = Field(None, description="System metric")
|
||||
min_value: float | None = Field(None, description="Min value")
|
||||
max_value: float | None = Field(None, description="Max value")
|
||||
max_rate: float | None = Field(None, description="Max rate bytes/sec")
|
||||
disk_path: str | None = Field(None, description="Disk path")
|
||||
sensor_label: str | None = Field(None, description="Sensor label")
|
||||
poll_interval: float | None = Field(None, description="Poll interval", ge=0.1, le=60.0)
|
||||
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["http"] = "http"
|
||||
http_endpoint_id: str | None = Field(None, description="HTTP endpoint ID")
|
||||
json_path: str | None = Field(None, description="Dot-path into the response")
|
||||
interval_s: int | None = Field(None, description="Polling cadence (seconds)", ge=1)
|
||||
min_value: float | None = Field(None, description="Raw value mapped to 0.0")
|
||||
max_value: float | None = Field(None, description="Raw value mapped to 1.0")
|
||||
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
ValueSourceUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceUpdate, Tag("static")],
|
||||
Annotated[AnimatedValueSourceUpdate, Tag("animated")],
|
||||
Annotated[AudioValueSourceUpdate, Tag("audio")],
|
||||
Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")],
|
||||
Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")],
|
||||
Annotated[DaylightValueSourceUpdate, Tag("daylight")],
|
||||
Annotated[StaticColorValueSourceUpdate, Tag("static_color")],
|
||||
Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")],
|
||||
Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")],
|
||||
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")],
|
||||
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
|
||||
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")],
|
||||
],
|
||||
Annotated[StaticValueSourceUpdate, Tag("static")]
|
||||
| Annotated[AnimatedValueSourceUpdate, Tag("animated")]
|
||||
| Annotated[AudioValueSourceUpdate, Tag("audio")]
|
||||
| Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")]
|
||||
| Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")]
|
||||
| Annotated[DaylightValueSourceUpdate, Tag("daylight")]
|
||||
| Annotated[StaticColorValueSourceUpdate, Tag("static_color")]
|
||||
| Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")]
|
||||
| Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")]
|
||||
| Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")]
|
||||
| Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")]
|
||||
| Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")]
|
||||
| Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")]
|
||||
| Annotated[HTTPValueSourceUpdate, Tag("http")],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Weather source schemas (CRUD)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
from typing import Dict, List, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -13,7 +13,7 @@ class WeatherSourceCreate(BaseModel):
|
||||
provider: Literal["open_meteo"] = Field(
|
||||
default="open_meteo", description="Weather data provider"
|
||||
)
|
||||
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
|
||||
provider_config: Dict | None = Field(None, description="Provider-specific configuration")
|
||||
latitude: float = Field(
|
||||
default=50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0
|
||||
)
|
||||
@@ -23,27 +23,47 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class WeatherSourceResponse(BaseModel):
|
||||
@@ -58,8 +78,18 @@ 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: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
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")
|
||||
|
||||
|
||||
@@ -83,52 +83,10 @@ class StorageConfig(BaseSettings):
|
||||
database_file: str = f"{_DEFAULT_DATA_DIR_STR}/ledgrab.db"
|
||||
|
||||
|
||||
class MQTTConfig(BaseSettings):
|
||||
"""MQTT broker configuration.
|
||||
|
||||
The ``password`` field accepts either plaintext or an ``ENC:v1:`` envelope
|
||||
(see :mod:`ledgrab.utils.secret_box`). Use :func:`resolve_mqtt_password`
|
||||
to obtain the plaintext value at runtime.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
broker_host: str = "localhost"
|
||||
broker_port: int = 1883
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
client_id: str = "ledgrab"
|
||||
base_topic: str = "ledgrab"
|
||||
|
||||
|
||||
def resolve_mqtt_password(cfg: "Config | None" = None) -> str:
|
||||
"""Return the plaintext MQTT password.
|
||||
|
||||
Accepts either an ``ENC:v1:`` envelope or legacy plaintext. If
|
||||
plaintext is detected, a warning is logged once per process start
|
||||
so the user knows to migrate.
|
||||
"""
|
||||
from ledgrab.utils import get_logger, secret_box
|
||||
|
||||
log = get_logger(__name__)
|
||||
cfg = cfg or get_config()
|
||||
pw = cfg.mqtt.password or ""
|
||||
if not pw:
|
||||
return ""
|
||||
if secret_box.is_encrypted(pw):
|
||||
try:
|
||||
return secret_box.decrypt(pw)
|
||||
except Exception as exc:
|
||||
log.error("Failed to decrypt MQTT password: %s", exc)
|
||||
return ""
|
||||
# Plaintext — warn (once)
|
||||
if not getattr(resolve_mqtt_password, "_warned", False):
|
||||
log.warning(
|
||||
"MQTT password in config.yaml is stored in plaintext. "
|
||||
"Replace with an encrypted envelope (ENC:v1:...) — see "
|
||||
"ledgrab.utils.secret_box.encrypt()."
|
||||
)
|
||||
resolve_mqtt_password._warned = True # type: ignore[attr-defined]
|
||||
return pw
|
||||
# The legacy single-broker ``MQTTConfig`` block has been removed. Brokers
|
||||
# are now first-class :class:`MQTTSource` entries managed through the UI;
|
||||
# see :mod:`ledgrab.core.mqtt.legacy_migration` for the one-shot upgrade
|
||||
# path that seeds an MQTTSource from any pre-existing ``mqtt:`` YAML block.
|
||||
|
||||
|
||||
class LoggingConfig(BaseSettings):
|
||||
@@ -158,6 +116,10 @@ class Config(BaseSettings):
|
||||
env_prefix="LEDGRAB_",
|
||||
env_nested_delimiter="__",
|
||||
case_sensitive=False,
|
||||
# ``extra="ignore"`` lets pre-existing YAML files (with the now-removed
|
||||
# ``mqtt:`` block, etc.) load without raising. The legacy MQTT block
|
||||
# is handled by ``core.mqtt.legacy_migration`` on first startup.
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
demo: bool = False
|
||||
@@ -166,7 +128,6 @@ class Config(BaseSettings):
|
||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
assets: AssetsConfig = Field(default_factory=AssetsConfig)
|
||||
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
updates: UpdatesConfig = Field(default_factory=UpdatesConfig)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional, Set
|
||||
from typing import Callable, Dict, Set
|
||||
|
||||
from ledgrab.core.automations.platform_detector import PlatformDetector
|
||||
from ledgrab.storage.automation import (
|
||||
@@ -11,6 +12,7 @@ from ledgrab.storage.automation import (
|
||||
Automation,
|
||||
DisplayStateRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
MQTTRule,
|
||||
Rule,
|
||||
StartupRule,
|
||||
@@ -25,6 +27,53 @@ from ledgrab.utils import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RuleEvalContext:
|
||||
"""Per-tick environment passed to every rule handler.
|
||||
|
||||
Bundles all the cross-cutting state the various ``_evaluate_*``
|
||||
handlers need so adding a new handler does not require widening
|
||||
``_evaluate_rule``'s parameter list. ``frozen=True`` guards against
|
||||
a handler mutating its inputs.
|
||||
"""
|
||||
|
||||
running_procs: Set[str]
|
||||
topmost_proc: str | None
|
||||
topmost_fullscreen: bool
|
||||
fullscreen_procs: Set[str]
|
||||
idle_seconds: float | None
|
||||
display_state: str | None
|
||||
|
||||
|
||||
def _apply_operator(operator: str, extracted, expected: str) -> bool:
|
||||
"""Compare *extracted* against *expected* using *operator*.
|
||||
|
||||
String operators (equals, not_equals, contains, regex) coerce the
|
||||
extracted value to str. Numeric operators (gt, lt) coerce both sides
|
||||
to float and return False on parse failure.
|
||||
"""
|
||||
if operator == "equals":
|
||||
return str(extracted) == expected
|
||||
if operator == "not_equals":
|
||||
return str(extracted) != expected
|
||||
if operator == "contains":
|
||||
return expected in str(extracted)
|
||||
if operator == "regex":
|
||||
try:
|
||||
return bool(re.search(expected, str(extracted)))
|
||||
except re.error as exc:
|
||||
logger.debug("HTTP poll rule regex error: %s", exc)
|
||||
return False
|
||||
if operator in ("gt", "lt"):
|
||||
try:
|
||||
lhs = float(extracted)
|
||||
rhs = float(expected)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return lhs > rhs if operator == "gt" else lhs < rhs
|
||||
return False
|
||||
|
||||
|
||||
class AutomationEngine:
|
||||
"""Evaluates automation rules and activates/deactivates scene presets."""
|
||||
|
||||
@@ -33,24 +82,26 @@ class AutomationEngine:
|
||||
automation_store: AutomationStore,
|
||||
processor_manager,
|
||||
poll_interval: float = 1.0,
|
||||
mqtt_service=None,
|
||||
scene_preset_store=None,
|
||||
target_store=None,
|
||||
device_store=None,
|
||||
ha_manager=None,
|
||||
mqtt_manager=None,
|
||||
value_stream_manager=None,
|
||||
value_source_store=None,
|
||||
):
|
||||
self._store = automation_store
|
||||
self._manager = processor_manager
|
||||
self._poll_interval = poll_interval
|
||||
self._detector = PlatformDetector()
|
||||
self._mqtt_service = mqtt_service
|
||||
self._mqtt_manager = mqtt_manager
|
||||
self._value_stream_manager = value_stream_manager
|
||||
self._value_source_store = value_source_store
|
||||
self._scene_preset_store = scene_preset_store
|
||||
self._target_store = target_store
|
||||
self._device_store = device_store
|
||||
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)
|
||||
@@ -67,12 +118,15 @@ class AutomationEngine:
|
||||
self._ha_acquired: Set[str] = set()
|
||||
# MQTT source IDs currently acquired by the engine
|
||||
self._mqtt_acquired: Set[str] = set()
|
||||
# Value source IDs currently acquired by the engine (for HTTPPollRule)
|
||||
self._value_sources_acquired: Set[str] = set()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is not None:
|
||||
return
|
||||
await self._sync_ha_runtimes()
|
||||
await self._sync_mqtt_runtimes()
|
||||
self._sync_value_stream_refs()
|
||||
self._task = asyncio.create_task(self._poll_loop())
|
||||
logger.info("Automation engine started")
|
||||
|
||||
@@ -96,6 +150,8 @@ class AutomationEngine:
|
||||
await self._release_all_ha_runtimes()
|
||||
# Release all MQTT runtimes
|
||||
await self._release_all_mqtt_runtimes()
|
||||
# Release all value-stream refs held for HTTPPollRule evaluation
|
||||
self._release_all_value_stream_refs()
|
||||
|
||||
logger.info("Automation engine stopped")
|
||||
|
||||
@@ -185,6 +241,53 @@ class AutomationEngine:
|
||||
logger.warning("Failed to release MQTT runtime %s: %s", source_id, e)
|
||||
self._mqtt_acquired = set()
|
||||
|
||||
def _get_needed_value_sources(self) -> Set[str]:
|
||||
"""Collect value source IDs referenced by enabled HTTPPollRule rules."""
|
||||
needed: Set[str] = set()
|
||||
if self._value_stream_manager is None:
|
||||
return needed
|
||||
for a in self._store.get_all_automations():
|
||||
if a.enabled:
|
||||
for r in a.rules:
|
||||
if isinstance(r, HTTPPollRule) and r.value_source_id:
|
||||
needed.add(r.value_source_id)
|
||||
return needed
|
||||
|
||||
def _sync_value_stream_refs(self) -> None:
|
||||
"""Acquire/release ValueStreams to keep HTTPPollRule sources polling.
|
||||
|
||||
Mirrors the HA/MQTT sync pattern, but talks to ``ValueStreamManager``
|
||||
(which is sync). Acquiring a stream both starts its background poll
|
||||
task and pins the ref count; releasing decrements.
|
||||
"""
|
||||
if self._value_stream_manager is None:
|
||||
return
|
||||
needed = self._get_needed_value_sources()
|
||||
for vs_id in self._value_sources_acquired - needed:
|
||||
try:
|
||||
self._value_stream_manager.release(vs_id)
|
||||
logger.debug("Released value stream for automation: %s", vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to release value stream %s: %s", vs_id, e)
|
||||
for vs_id in needed - self._value_sources_acquired:
|
||||
try:
|
||||
self._value_stream_manager.acquire(vs_id)
|
||||
logger.debug("Acquired value stream for automation: %s", vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to acquire value stream %s: %s", vs_id, e)
|
||||
self._value_sources_acquired = needed
|
||||
|
||||
def _release_all_value_stream_refs(self) -> None:
|
||||
"""Release all ValueStreams held for HTTPPollRule evaluation."""
|
||||
if self._value_stream_manager is None:
|
||||
return
|
||||
for vs_id in self._value_sources_acquired:
|
||||
try:
|
||||
self._value_stream_manager.release(vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to release value stream %s: %s", vs_id, e)
|
||||
self._value_sources_acquired = set()
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
@@ -200,6 +303,7 @@ class AutomationEngine:
|
||||
async def _evaluate_all(self) -> None:
|
||||
await self._sync_ha_runtimes()
|
||||
await self._sync_mqtt_runtimes()
|
||||
self._sync_value_stream_refs()
|
||||
async with self._eval_lock:
|
||||
await self._evaluate_all_locked()
|
||||
|
||||
@@ -316,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(
|
||||
@@ -339,32 +443,79 @@ class AutomationEngine:
|
||||
return all(results)
|
||||
return any(results) # "or" is default
|
||||
|
||||
# Per-rule-type handlers. Built once at class-definition time (see
|
||||
# ``_RULE_HANDLERS`` below) so the dispatch dict is not rebuilt on every
|
||||
# tick the way the old inline body used to. Each handler signature is
|
||||
# ``(self, rule, ctx: _RuleEvalContext) -> bool``.
|
||||
_RULE_HANDLERS: "Dict[type, Callable[..., bool]]"
|
||||
|
||||
def _evaluate_rule(
|
||||
self,
|
||||
rule: Rule,
|
||||
running_procs: Set[str],
|
||||
topmost_proc: Optional[str],
|
||||
topmost_proc: str | None,
|
||||
topmost_fullscreen: bool,
|
||||
fullscreen_procs: Set[str],
|
||||
idle_seconds: Optional[float],
|
||||
display_state: Optional[str],
|
||||
idle_seconds: float | None,
|
||||
display_state: str | None,
|
||||
) -> bool:
|
||||
dispatch = {
|
||||
StartupRule: lambda r: True,
|
||||
ApplicationRule: lambda r: self._evaluate_app_rule(
|
||||
r, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
|
||||
),
|
||||
TimeOfDayRule: lambda r: self._evaluate_time_of_day(r),
|
||||
SystemIdleRule: lambda r: self._evaluate_idle(r, idle_seconds),
|
||||
DisplayStateRule: lambda r: self._evaluate_display_state(r, display_state),
|
||||
MQTTRule: lambda r: self._evaluate_mqtt(r),
|
||||
WebhookRule: lambda r: self._webhook_states.get(r.token, False),
|
||||
HomeAssistantRule: lambda r: self._evaluate_home_assistant(r),
|
||||
}
|
||||
handler = dispatch.get(type(rule))
|
||||
ctx = _RuleEvalContext(
|
||||
running_procs=running_procs,
|
||||
topmost_proc=topmost_proc,
|
||||
topmost_fullscreen=topmost_fullscreen,
|
||||
fullscreen_procs=fullscreen_procs,
|
||||
idle_seconds=idle_seconds,
|
||||
display_state=display_state,
|
||||
)
|
||||
handler = self._RULE_HANDLERS.get(type(rule))
|
||||
if handler is None:
|
||||
# Coverage of ``_RULE_HANDLERS`` is asserted at module import,
|
||||
# so reaching this branch means a Rule subclass slipped past
|
||||
# the assertion (e.g. a hand-built test instance). Log loudly
|
||||
# and fall back to the previous "treat as inactive" semantics.
|
||||
logger.warning(
|
||||
"No handler registered for rule type %s — treating as inactive",
|
||||
type(rule).__name__,
|
||||
)
|
||||
return False
|
||||
return handler(rule)
|
||||
return handler(self, rule, ctx)
|
||||
|
||||
# -- Per-rule-type handlers --
|
||||
# Bound to ``self`` via ``_RULE_HANDLERS`` lookup; each signature is
|
||||
# ``(self, rule, ctx: _RuleEvalContext) -> bool``.
|
||||
|
||||
def _handle_startup(self, rule: StartupRule, ctx: _RuleEvalContext) -> bool:
|
||||
return True
|
||||
|
||||
def _handle_application(self, rule: ApplicationRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_app_rule(
|
||||
rule,
|
||||
ctx.running_procs,
|
||||
ctx.topmost_proc,
|
||||
ctx.topmost_fullscreen,
|
||||
ctx.fullscreen_procs,
|
||||
)
|
||||
|
||||
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_time_of_day(rule)
|
||||
|
||||
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_idle(rule, ctx.idle_seconds)
|
||||
|
||||
def _handle_display_state(self, rule: DisplayStateRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_display_state(rule, ctx.display_state)
|
||||
|
||||
def _handle_mqtt(self, rule: MQTTRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_mqtt(rule)
|
||||
|
||||
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._webhook_states.get(rule.token, False)
|
||||
|
||||
def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_home_assistant(rule)
|
||||
|
||||
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_http_poll(rule)
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
||||
@@ -380,33 +531,27 @@ 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
|
||||
|
||||
def _evaluate_mqtt(self, rule: MQTTRule) -> bool:
|
||||
value = None
|
||||
# Try entity-based manager first (new model)
|
||||
if self._mqtt_manager is not None and rule.mqtt_source_id:
|
||||
runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
|
||||
if runtime and runtime.is_connected:
|
||||
value = runtime.get_last_value(rule.topic)
|
||||
elif self._mqtt_manager is not None:
|
||||
# No source specified — try first available runtime
|
||||
runtime = self._mqtt_manager.get_first_runtime()
|
||||
if runtime:
|
||||
value = runtime.get_last_value(rule.topic)
|
||||
# Fallback to legacy global service
|
||||
if value is None and self._mqtt_service is not None and self._mqtt_service.is_connected:
|
||||
value = self._mqtt_service.get_last_value(rule.topic)
|
||||
# Multi-broker model: the rule references a specific MQTTSource.
|
||||
# Rules without one are no-ops (UI should enforce a source on save).
|
||||
if self._mqtt_manager is None or not rule.mqtt_source_id:
|
||||
return False
|
||||
runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
|
||||
if runtime is None or not runtime.is_connected:
|
||||
return False
|
||||
value = runtime.get_last_value(rule.topic)
|
||||
if value is None:
|
||||
return False
|
||||
matchers = {
|
||||
@@ -444,11 +589,30 @@ class AutomationEngine:
|
||||
logger.debug("HA rule regex error: %s", e)
|
||||
return False
|
||||
|
||||
def _evaluate_http_poll(self, rule: HTTPPollRule) -> bool:
|
||||
"""Evaluate an HTTPPollRule by reading the referenced value source.
|
||||
|
||||
The value source (HTTPValueSource → HTTPValueStream) handles the
|
||||
actual polling + JSON extraction; the rule only compares the
|
||||
already-extracted raw value to ``rule.value`` via ``operator``.
|
||||
"""
|
||||
if self._value_stream_manager is None or not rule.value_source_id:
|
||||
return False
|
||||
stream = self._value_stream_manager.peek(rule.value_source_id)
|
||||
if stream is None or not hasattr(stream, "get_raw_value"):
|
||||
return False
|
||||
raw = stream.get_raw_value()
|
||||
if rule.operator == "exists":
|
||||
return raw is not None
|
||||
if raw is None:
|
||||
return False
|
||||
return _apply_operator(rule.operator, raw, rule.value)
|
||||
|
||||
def _evaluate_app_rule(
|
||||
self,
|
||||
rule: ApplicationRule,
|
||||
running_procs: Set[str],
|
||||
topmost_proc: Optional[str],
|
||||
topmost_proc: str | None,
|
||||
topmost_fullscreen: bool,
|
||||
fullscreen_procs: Set[str],
|
||||
) -> bool:
|
||||
@@ -644,3 +808,57 @@ class AutomationEngine:
|
||||
"""Deactivate an automation immediately (used when disabling/deleting)."""
|
||||
if automation_id in self._active_automations:
|
||||
await self._deactivate_automation(automation_id)
|
||||
|
||||
|
||||
# Bind the per-rule-type handler table once after the class is fully defined.
|
||||
# This replaces the per-call dict-rebuild that the inline ``_evaluate_rule``
|
||||
# used to do and gives us a single place to assert coverage against the
|
||||
# Rule subclass set imported from storage.
|
||||
AutomationEngine._RULE_HANDLERS = {
|
||||
StartupRule: AutomationEngine._handle_startup,
|
||||
ApplicationRule: AutomationEngine._handle_application,
|
||||
TimeOfDayRule: AutomationEngine._handle_time_of_day,
|
||||
SystemIdleRule: AutomationEngine._handle_system_idle,
|
||||
DisplayStateRule: AutomationEngine._handle_display_state,
|
||||
MQTTRule: AutomationEngine._handle_mqtt,
|
||||
WebhookRule: AutomationEngine._handle_webhook,
|
||||
HomeAssistantRule: AutomationEngine._handle_home_assistant,
|
||||
HTTPPollRule: AutomationEngine._handle_http_poll,
|
||||
}
|
||||
|
||||
|
||||
def _assert_rule_handler_coverage() -> None:
|
||||
"""Every concrete Rule subclass imported by this module must have a handler.
|
||||
|
||||
Runs at module import so a new Rule subclass added without an
|
||||
accompanying ``_handle_*`` method + ``_RULE_HANDLERS`` entry fails the
|
||||
server boot loudly instead of silently being dropped on the floor by
|
||||
``_evaluate_rule``'s "no handler → False" fallback.
|
||||
"""
|
||||
expected = {
|
||||
StartupRule,
|
||||
ApplicationRule,
|
||||
TimeOfDayRule,
|
||||
SystemIdleRule,
|
||||
DisplayStateRule,
|
||||
MQTTRule,
|
||||
WebhookRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
}
|
||||
registered = set(AutomationEngine._RULE_HANDLERS.keys())
|
||||
missing = expected - registered
|
||||
extra = registered - expected
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing handlers: {sorted(c.__name__ for c in missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
|
||||
raise RuntimeError(
|
||||
"AutomationEngine._RULE_HANDLERS is out of sync with imported Rule subclasses: "
|
||||
+ "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_rule_handler_coverage()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user