Compare commits
89 Commits
v0.4.1
...
fd46c51dba
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| c44bb38c43 | |||
| be2d5e1670 | |||
| 5db6eddcf8 | |||
| a8a4296a56 | |||
| 9ce1dc33bf | |||
| 03d2e6b1f2 |
@@ -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:
|
||||
|
||||
@@ -191,11 +191,21 @@ jobs:
|
||||
echo "Uploaded: $NAME"
|
||||
}
|
||||
|
||||
# Publish an asset plus its .sha256 sidecar. The in-app update
|
||||
# service refuses to install without a published checksum, so
|
||||
# every artifact needs its hash uploaded alongside.
|
||||
upload_with_sha256() {
|
||||
local FILE="$1"
|
||||
upload_asset "$FILE"
|
||||
(cd "$(dirname "$FILE")" && sha256sum "$(basename "$FILE")" > "$(basename "$FILE").sha256")
|
||||
upload_asset "$FILE.sha256"
|
||||
}
|
||||
|
||||
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
|
||||
[ -f "$ZIP_FILE" ] && upload_asset "$ZIP_FILE"
|
||||
[ -f "$ZIP_FILE" ] && upload_with_sha256 "$ZIP_FILE"
|
||||
|
||||
SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1)
|
||||
[ -f "$SETUP_FILE" ] && upload_asset "$SETUP_FILE"
|
||||
[ -f "$SETUP_FILE" ] && upload_with_sha256 "$SETUP_FILE"
|
||||
|
||||
# ── Linux tarball ──────────────────────────────────────────
|
||||
build-linux:
|
||||
@@ -242,26 +252,34 @@ jobs:
|
||||
run: |
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
upload_asset() {
|
||||
local FILE="$1"
|
||||
local NAME
|
||||
NAME=$(basename "$FILE")
|
||||
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'),''))" 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
echo "Replaced existing asset: $NAME"
|
||||
fi
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$FILE"
|
||||
echo "Uploaded: $NAME"
|
||||
}
|
||||
|
||||
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
|
||||
TAR_NAME=$(basename "$TAR_FILE")
|
||||
|
||||
# Delete existing asset with same name to prevent duplicates on re-run
|
||||
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$TAR_NAME'),''))" 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
echo "Replaced existing asset: $TAR_NAME"
|
||||
if [ -f "$TAR_FILE" ]; then
|
||||
upload_asset "$TAR_FILE"
|
||||
(cd "$(dirname "$TAR_FILE")" && sha256sum "$(basename "$TAR_FILE")" > "$(basename "$TAR_FILE").sha256")
|
||||
upload_asset "$TAR_FILE.sha256"
|
||||
fi
|
||||
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$TAR_NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$TAR_FILE"
|
||||
echo "Uploaded: $TAR_NAME"
|
||||
|
||||
# ── Docker image ───────────────────────────────────────────
|
||||
build-docker:
|
||||
needs: create-release
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-2
@@ -62,8 +62,17 @@ htmlcov/
|
||||
logs/
|
||||
*.log.*
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
# Runtime data — anchor to repo root so nested package data dirs
|
||||
# (server/src/ledgrab/data/prebuilt_sounds, game_adapters) are NOT ignored.
|
||||
# An unanchored `data/` rule silently broke the v0.4.2 release by keeping
|
||||
# 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
|
||||
@@ -86,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+24
-10
@@ -1,18 +1,28 @@
|
||||
## v0.4.1 (2026-04-22)
|
||||
## v0.6.1 (2026-05-10)
|
||||
|
||||
### Features
|
||||
|
||||
- Per-surface card presentation modes (C/M/D/R) for the UI ([75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487))
|
||||
- Customisable card icon for all entity types ([0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e))
|
||||
- HA-Light: broadcast a single Color Value Source to all entities ([a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf))
|
||||
- Targets: customisable card icon plus HA-light stop action ([ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc))
|
||||
- Customisable card icon plate for devices ([49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb))
|
||||
|
||||
### Bug Fixes
|
||||
- Installer now bundles `cryptography` and `just-playback`, sets the `TCL` environment for Tk, and removes the stale `debug.bat` shim ([4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c))
|
||||
|
||||
- Shutdown: apply target stop actions before tearing down HA/MQTT so devices end up in their configured state ([6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### CI/Build
|
||||
- Scope the Android keystore env correctly and fail loudly when a release build is attempted without a signing key ([35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2))
|
||||
|
||||
#### Documentation
|
||||
- Drop the stale WLED-rename task and document the Android signing secrets ([a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3))
|
||||
- Remove WLED-specific language from the auto-generated release notes template ([4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d))
|
||||
- Android: fail-fast on missing release keystore before SDK setup ([a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b))
|
||||
|
||||
#### Chores
|
||||
|
||||
- Clean up `cfg` abbreviation and stale TODO link ([e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4))
|
||||
|
||||
---
|
||||
|
||||
@@ -21,9 +31,13 @@
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c) | fix(installer): bundle cryptography + just-playback, set TCL env, clean stale debug.bat | alexei.dolgolyov |
|
||||
| [a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3) | docs(release): drop stale WLED-rename task, document android signing secrets | alexei.dolgolyov |
|
||||
| [35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2) | ci(android): fix keystore env scoping, fail loudly on release without key | alexei.dolgolyov |
|
||||
| [4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d) | docs(release): drop WLED-specific language from auto-generated release notes | alexei.dolgolyov |
|
||||
| [75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487) | feat(ui): per-surface card presentation modes (C/M/D/R) | alexei.dolgolyov |
|
||||
| [e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4) | chore: clean up cfg abbreviation and stale TODO link | alexei.dolgolyov |
|
||||
| [6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b) | fix(shutdown): apply target stop actions before tearing down HA/MQTT | alexei.dolgolyov |
|
||||
| [0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e) | feat(ui): customisable card icon for all entity types | alexei.dolgolyov |
|
||||
| [a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf) | feat(ha-light): broadcast a single Color Value Source to all entities | alexei.dolgolyov |
|
||||
| [ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc) | feat(targets): customisable card icon + HA-light stop action | alexei.dolgolyov |
|
||||
| [49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb) | feat(ui): customisable card icon plate for devices | alexei.dolgolyov |
|
||||
| [a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b) | ci(android): fail-fast on missing release keystore before SDK setup | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
# Production Review — Remaining Items
|
||||
|
||||
Output of the multi-agent production review (security / Python / TypeScript /
|
||||
performance / architecture / code-quality). Each entry below is something
|
||||
the original audit flagged and the autonomous hardening pass deliberately
|
||||
did **not** address — either because it needs design input, profiling
|
||||
validation, or a multi-day refactor that should land in its own session.
|
||||
|
||||
The hardening pass landed everything else: see git log between `master` and
|
||||
the head of the review branch for the applied changes (URL-scheme +
|
||||
malicious-input rejection, IconSelect XSS escape, MiniSelect for forbidden
|
||||
plain `<select>`s, WebSocket Origin allow-list, /docs auth-gate, security
|
||||
headers middleware, streaming upload size caps, fire-and-forget task
|
||||
tracking + drain resilience in MQTT runtime, discovery_watcher task
|
||||
tracking, asyncio.gather return_exceptions, secret_box encryption for MQTT
|
||||
/ Hue / Govee credentials with auto-migration, SSRF-validated update
|
||||
redirects, single source of truth for IP classification in
|
||||
`utils/net_classify.py`, allowlist + parity test for inbound WS events,
|
||||
typed `Window` globals, and more).
|
||||
|
||||
---
|
||||
|
||||
## Architecture refactors (multi-day — own session)
|
||||
|
||||
- [ ] **Split `core/processing/value_stream.py`** (1856 LOC, 14 stream classes)
|
||||
into a `value_streams/` package. Each value-stream type gets its own
|
||||
file ≤300 LOC; `manager.py` holds `ValueStreamManager`.
|
||||
- [ ] **Split `storage/color_strip_source.py`** (1841 LOC, 18 source kinds)
|
||||
into a `color_strip_sources/` package mirroring `value_streams/`.
|
||||
- [ ] **Frontend file splits** — `graph-editor.ts` (2707), `streams.ts`
|
||||
(2335), `value-sources.ts` (1889), `types.ts` (1062). Highest-churn
|
||||
modules; mixed UI / state / network responsibilities.
|
||||
- [ ] **Layering reversal**: introduce a neutral `domain/` package and move
|
||||
shared DTOs (`FilterInstance`, `CalibrationConfig`, etc.) into it so
|
||||
`storage/` no longer imports `core/`. Eliminates 7+ layering
|
||||
violations and the lazy-import hacks used to break the resulting
|
||||
circulars.
|
||||
- [ ] **`main.py` boot refactor** — extract import-time side effects into
|
||||
`bootstrap.py` + `create_app()` factory. `lifespan()` becomes the
|
||||
single place that wires stores and managers.
|
||||
- [ ] **DI consolidation** — replace `api/dependencies.py` getter sprawl
|
||||
(30+ `get_*()` functions reading a process-global `_deps` dict) with
|
||||
a single typed `get_container()` dependency. Makes test-overrides
|
||||
trivial; ban direct getter calls in handler bodies.
|
||||
- [ ] **Exception hierarchy** — define `ledgrab/errors.py` (`LedGrabError`,
|
||||
`NotFoundError`, `ValidationError`, `RemoteUnavailableError`,
|
||||
`SSRFBlockedError`). Move HTTP translation into a FastAPI exception
|
||||
handler. Stop raising `HTTPException` from `utils/safe_source.py`.
|
||||
- [ ] **Lazy-import audit** — 289 in-function `from ledgrab.*` imports.
|
||||
Specifically `core/processing/daylight_settings.py` imports
|
||||
`api.dependencies` (core → api inversion). Pass the database in via
|
||||
the constructor instead of service-locator lookup.
|
||||
|
||||
## Performance (profile before applying)
|
||||
|
||||
- [ ] **`composite_stream.py` blend modes** — pre-allocate scratch buffers
|
||||
in `_blend_override / overlay / hard_light / soft_light / difference
|
||||
/ exclusion`. Each currently allocates per frame (`mul`, `scr`,
|
||||
`blended`, `np.where(...)`). At 100 LEDs × 30 fps × N layers this
|
||||
adds up.
|
||||
- [ ] **`mapped_stream` / `composite_stream` zone resize** — replace the
|
||||
per-channel `np.interp` calls with a cached `floor/ceil/frac` LUT
|
||||
(same trick as `wled_target_processor._fit_to_device`) or a single
|
||||
`cv2.resize` call on the (N,3) array. `np.interp` allocates a new
|
||||
`float64` array per channel per frame even on cache-hit.
|
||||
- [ ] **`processed_stream._processing_loop`** — add ping-pong output
|
||||
buffers and pass them as `out=` to filter `process_strip()` calls.
|
||||
Today every filter that returns a fresh allocation costs us a copy
|
||||
per frame. Also: the loop uses `time.sleep` instead of an
|
||||
event-driven wait on the input stream — input updates faster than
|
||||
30 fps see up to `frame_time` of latency.
|
||||
- [ ] **`mqtt_client.py` `send_pixels`** — add a binary publish path (or
|
||||
at minimum cache the outer dict skeleton). Today every frame
|
||||
`pixels.tolist()` + `json.dumps` for ~300 LEDs × 30 fps × N devices.
|
||||
- [ ] **Frontend `static/js/features/color-strips/test.ts`** — cache
|
||||
`ImageData` per canvas (`canvas._imageData`); only re-create on
|
||||
dimension change; use a `Uint32Array` view to copy pixels in one
|
||||
loop instead of the per-pixel JS loop. Border-overlay rebuild on
|
||||
every frame should also be debounced to dimension changes only.
|
||||
- [ ] **`ws_stream.py` composite branch** — pre-allocate a `bytearray`
|
||||
sized to the largest frame and write into slices instead of
|
||||
`b"".join(tobytes()) per layer` every iteration. Same anti-pattern
|
||||
in `wled_target_processor._broadcast_led_preview`.
|
||||
- [ ] **Preview broadcast slow-client guard** — `asyncio.gather` over
|
||||
preview clients waits for the slowest. Move to `asyncio.wait` with a
|
||||
timeout and drop slow clients, or fire-and-forget with a
|
||||
`ws.application_state` filter.
|
||||
|
||||
## Security (deferred — non-trivial or design-sensitive)
|
||||
|
||||
- [ ] **Content-Security-Policy header** — would need careful tuning
|
||||
because the UI uses inline event handlers / Jinja templates.
|
||||
Mis-set CSP would break the app silently. Defer until templates can
|
||||
move to event-delegated handlers, then add a strict policy.
|
||||
- [ ] **`api/auth.py` exception specificity** — 9 `except Exception:`
|
||||
sites. Most are intentional best-effort `websocket.send_json`
|
||||
swallows (the WS is already closed or about to be), but the auth
|
||||
decision path itself could be tightened to specific types
|
||||
(`jwt.InvalidTokenError`, `OSError`) + `logger.exception` for
|
||||
observability.
|
||||
- [ ] **Hue bridge cert pinning** — `httpx.AsyncClient(verify=False)` for
|
||||
Hue bridge (self-signed cert by design). Should record the
|
||||
certificate fingerprint at pairing time and pin it on subsequent
|
||||
requests; otherwise an on-path attacker can MITM the bridge.
|
||||
|
||||
## Mechanical / code-quality (low risk, high line-count)
|
||||
|
||||
- [ ] **i18n parity** — **328** keys missing in `ru.json`, **325** missing
|
||||
in `zh.json`. Examples: `section.hide`, `filters.hsl_shift`,
|
||||
`filters.contrast`, `filters.temporal_blur`,
|
||||
`filters.audio_filter_template.desc`. Russian and Chinese users
|
||||
currently see raw keys for these. This is translation work, not
|
||||
code work.
|
||||
- [ ] **`Optional[T]` → `T | None`** (PEP 604) — large mechanical refactor
|
||||
across the codebase. Can be auto-fixed via `ruff check --fix
|
||||
--select UP007`. Worth doing once the file splits land.
|
||||
- [ ] **Hot-path `logger.error(f"...")` → `logger.error("... %s", e)`**
|
||||
lazy-eval — mostly cosmetic; ~200 sites. The f-string still builds
|
||||
the message even when DEBUG is off.
|
||||
- [ ] **Remaining `(window as any)` sites** — typed `global-types.d.ts`
|
||||
is in place and new code uses `window.foo` directly, but ~80
|
||||
existing sites still have the cast. Per-site mechanical cleanup.
|
||||
Add `eslint`-equivalent guard (TS rule) to prevent new ones.
|
||||
- [ ] **Magic numbers → named constants** in processing hot paths —
|
||||
`_FILTER_RECHECK_EVERY_N_FRAMES = 30` in
|
||||
`core/processing/processed_stream.py:159`; `5 ms` / `5 s` /
|
||||
`30 iterations` literals in `wled_target_processor.py:890,893,915`.
|
||||
- [ ] **Standardise `from __future__ import annotations`** across the
|
||||
codebase. Some modules use the future-annotation form, others stick
|
||||
with `Optional[...]`. Enforce one via ruff `FA` rules.
|
||||
|
||||
## Test gaps
|
||||
|
||||
- [ ] **Route-level integration test** for the WLED scheme inference —
|
||||
POST `/api/v1/devices` with `{"url": "192.168.1.42",
|
||||
"device_type": "wled"}` and assert the stored device has
|
||||
`url == "http://192.168.1.42"`. The helper is exhaustively
|
||||
unit-tested but no integration test exercises the create/update
|
||||
flow end-to-end.
|
||||
- [ ] **IPv6 public address regression** — extend `test_url_scheme.py`
|
||||
with explicit assertions for `2001:db8::1` and similar public IPv6
|
||||
literals (the bare-label fallback used to misclassify these). The
|
||||
helper does the right thing today via the IPv6 probe added during
|
||||
the hardening pass, but no test pins it.
|
||||
|
||||
## Pre-existing issues surfaced during the audit (not in our diff)
|
||||
|
||||
These were flagged by the auditors but predate the review session — kept
|
||||
here as a future-work backlog:
|
||||
|
||||
- [ ] **`icon-select.ts:_buildGrid` `item.icon` is interpolated raw** —
|
||||
documented as "trusted SVG by design". If callers ever feed
|
||||
user-supplied icon strings, that's an XSS sink. Audit every caller
|
||||
that builds `IconSelectItem.icon` from non-constant data and
|
||||
reject HTML there.
|
||||
- [ ] **`devices.py:461` `manager.update_device_info(device_url=update_data.url)`**
|
||||
receives `None` when a PATCH omits `url` (rename / icon-only edit).
|
||||
The processor never re-syncs in that case. Should pass
|
||||
`existing.url` (after normalization) or skip the call.
|
||||
- [ ] **`asyncio.gather` over uncapped client lists** in preview broadcasts
|
||||
— slow clients block the loop. Already noted under Performance
|
||||
above; pre-existing.
|
||||
@@ -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.1"
|
||||
versionName = "0.6.1"
|
||||
|
||||
ndk {
|
||||
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
||||
|
||||
@@ -69,6 +69,16 @@ copy_app_files() {
|
||||
# Clean up source maps and __pycache__
|
||||
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
|
||||
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Patch the fallback version in the bundled __init__.py. Bundled installs
|
||||
# strip ledgrab-*.dist-info from site-packages, so importlib.metadata
|
||||
# falls back to this literal at runtime — and a stale literal is what
|
||||
# silently shipped v0.4.2 reporting "0.3.0" in the WebUI.
|
||||
local bundled_init="$APP_DIR/src/ledgrab/__init__.py"
|
||||
if [ -f "$bundled_init" ] && [ -n "${VERSION_CLEAN:-}" ]; then
|
||||
sed -i "s/_FALLBACK_VERSION = \"[^\"]*\"/_FALLBACK_VERSION = \"${VERSION_CLEAN}\"/" "$bundled_init"
|
||||
echo " Patched _FALLBACK_VERSION -> ${VERSION_CLEAN}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Site-packages cleanup ────────────────────────────────────
|
||||
|
||||
@@ -196,6 +196,17 @@ New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
|
||||
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Patch the fallback version in the bundled __init__.py so the WebUI always
|
||||
# reports the release version — the installer strips ledgrab-*.dist-info from
|
||||
# site-packages (above), so importlib.metadata falls back to this literal.
|
||||
$bundledInit = Join-Path $srcDest "ledgrab\__init__.py"
|
||||
if (Test-Path $bundledInit) {
|
||||
$initContent = Get-Content $bundledInit -Raw
|
||||
$patched = [regex]::Replace($initContent, '_FALLBACK_VERSION\s*=\s*"[^"]*"', "_FALLBACK_VERSION = `"$VersionClean`"")
|
||||
Set-Content -Path $bundledInit -Value $patched -NoNewline
|
||||
Write-Host " Patched _FALLBACK_VERSION -> $VersionClean"
|
||||
}
|
||||
|
||||
# ── Create launcher ────────────────────────────────────────────
|
||||
|
||||
Write-Host "[8/8] Creating launcher..."
|
||||
|
||||
+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.1"
|
||||
version = "0.6.1"
|
||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
authors = [
|
||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||
|
||||
+86
-28
@@ -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,16 +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.
|
||||
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
|
||||
# on Android, where the source is included directly via source sets).
|
||||
_FALLBACK_VERSION = "0.3.0"
|
||||
# Fallback version — patched at build time by build/build-dist.ps1 so the
|
||||
# bundled Windows distribution reports the release version (the installer
|
||||
# strips ledgrab-*.dist-info, so importlib.metadata fails there).
|
||||
# In dev (running from source without `pip install -e .`) and on Android
|
||||
# (Chaquopy embeds the source directly with no dist-info), we additionally
|
||||
# read pyproject.toml so the version is always correct without manual sync.
|
||||
_FALLBACK_VERSION = "0.4.2"
|
||||
|
||||
try:
|
||||
__version__ = version("ledgrab")
|
||||
except PackageNotFoundError:
|
||||
__version__ = _FALLBACK_VERSION
|
||||
|
||||
def _read_pyproject_version() -> str | None:
|
||||
"""Read version from pyproject.toml (server/pyproject.toml relative to this file).
|
||||
|
||||
Returns None if the file is absent (typical for installed/bundled distributions
|
||||
where pyproject.toml isn't shipped) or unreadable.
|
||||
"""
|
||||
try:
|
||||
# __init__.py -> ledgrab/ -> src/ -> server/
|
||||
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
if not pyproject.is_file():
|
||||
return None
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError:
|
||||
return None
|
||||
with pyproject.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
v = data.get("project", {}).get("version")
|
||||
return v if isinstance(v, str) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Prefer pyproject.toml when it sits next to the source (dev checkout). This
|
||||
# avoids stale `pip install -e .` dist-info pinning an older version after a
|
||||
# bump. When pyproject.toml isn't shipped (installed packages, Windows bundle,
|
||||
# Android), fall back to importlib.metadata, then the patched literal.
|
||||
_live = _read_pyproject_version()
|
||||
if _live:
|
||||
__version__ = _live
|
||||
else:
|
||||
try:
|
||||
__version__ = version("ledgrab")
|
||||
except PackageNotFoundError:
|
||||
__version__ = _FALLBACK_VERSION
|
||||
|
||||
__author__ = "Alexei Dolgolyov"
|
||||
__email__ = "dolgolyov.alexei@gmail.com"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,14 +11,13 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Security scheme for Bearer token
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Return True when at least one API key is configured."""
|
||||
@@ -26,15 +25,15 @@ def is_auth_enabled() -> bool:
|
||||
|
||||
|
||||
def _is_loopback(host: str | None) -> bool:
|
||||
"""Return True when *host* is a loopback address."""
|
||||
"""Return True when *host* is a loopback address.
|
||||
|
||||
Delegates to :func:`ledgrab.utils.net_classify.is_loopback` so this
|
||||
auth gate, the SSRF guard in ``safe_source``, and the LAN-default
|
||||
inference in ``url_scheme`` share one classification source.
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
# Strip IPv6 brackets and zone IDs
|
||||
h = host.strip().lower()
|
||||
if h.startswith("[") and h.endswith("]"):
|
||||
h = h[1:-1]
|
||||
h = h.split("%", 1)[0]
|
||||
return h in _LOOPBACK_HOSTS
|
||||
return _classify_is_loopback(host)
|
||||
|
||||
|
||||
def verify_api_key(
|
||||
@@ -142,6 +141,23 @@ def require_authenticated(label: str) -> None:
|
||||
WS_AUTH_CLOSE_CODE = 4401
|
||||
|
||||
|
||||
WS_ORIGIN_CLOSE_CODE = 4403
|
||||
"""Close code sent when a WebSocket request fails the Origin allowlist."""
|
||||
|
||||
|
||||
def _is_origin_allowed(origin: str | None, allowed: list[str]) -> bool:
|
||||
"""Return True when *origin* matches one of the configured CORS origins.
|
||||
|
||||
Non-browser clients (Python scripts, curl) don't send Origin — those are
|
||||
allowed through; the Bearer-token check on the auth handshake is the
|
||||
primary defence in that case. Browsers always set Origin, so this only
|
||||
blocks cross-site WebSocket connection attempts (CSWSH).
|
||||
"""
|
||||
if not origin:
|
||||
return True
|
||||
return origin in set(allowed or [])
|
||||
|
||||
|
||||
async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) -> str | None:
|
||||
"""Accept the WebSocket, then perform first-message auth handshake.
|
||||
|
||||
@@ -152,6 +168,23 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
||||
Returns the caller label on success, ``None`` on failure (connection
|
||||
already closed).
|
||||
"""
|
||||
# Reject cross-site WebSocket attempts before accepting — a browser-based
|
||||
# attacker page cannot forge the Origin header, so an Origin mismatch is
|
||||
# a strong signal even before the token check. Non-browser clients
|
||||
# legitimately omit Origin; those fall through to the auth handshake.
|
||||
config = get_config()
|
||||
origin = websocket.headers.get("origin")
|
||||
if not _is_origin_allowed(origin, config.server.cors_origins):
|
||||
logger.warning(
|
||||
"Rejected WebSocket from origin %r (not in cors_origins)",
|
||||
origin,
|
||||
)
|
||||
try:
|
||||
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
await websocket.accept()
|
||||
label = await verify_ws_auth(websocket, timeout=timeout)
|
||||
if label is None:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ from ledgrab.api.schemas.assets import (
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import get_logger, read_upload_capped
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -93,10 +93,11 @@ async def upload_asset(
|
||||
config = get_config()
|
||||
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > max_size:
|
||||
try:
|
||||
data = await read_upload_capped(file, max_size)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=413,
|
||||
detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
|
||||
)
|
||||
|
||||
@@ -142,6 +143,8 @@ async def update_asset(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
tags=body.tags,
|
||||
icon=body.icon,
|
||||
icon_color=body.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
|
||||
|
||||
@@ -36,6 +36,8 @@ def _apt_to_response(t) -> AudioProcessingTemplateResponse:
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -73,6 +75,8 @@ async def create_audio_processing_template(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_processing_template", "created", template.id)
|
||||
return _apt_to_response(template)
|
||||
@@ -129,6 +133,8 @@ async def update_audio_processing_template(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_processing_template", "updated", template_id)
|
||||
# Hot-update: rebuild filter pipelines for running streams using this template
|
||||
|
||||
@@ -46,6 +46,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
device_index=s.device_index,
|
||||
is_loopback=s.is_loopback,
|
||||
audio_template_id=s.audio_template_id,
|
||||
@@ -57,6 +59,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
audio_source_id=s.audio_source_id,
|
||||
audio_processing_template_id=s.audio_processing_template_id,
|
||||
),
|
||||
@@ -75,6 +79,8 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
device_index=getattr(source, "device_index", -1),
|
||||
is_loopback=getattr(source, "is_loopback", True),
|
||||
audio_template_id=getattr(source, "audio_template_id", None),
|
||||
|
||||
@@ -53,6 +53,8 @@ async def list_audio_templates(
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
for t in templates
|
||||
]
|
||||
@@ -81,6 +83,8 @@ async def create_audio_template(
|
||||
engine_config=data.engine_config,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_template", "created", template.id)
|
||||
return AudioTemplateResponse(
|
||||
@@ -92,6 +96,8 @@ async def create_audio_template(
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
icon=getattr(template, "icon", "") or "",
|
||||
icon_color=getattr(template, "icon_color", "") or "",
|
||||
)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -127,6 +133,8 @@ async def get_audio_template(
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -150,6 +158,8 @@ async def update_audio_template(
|
||||
engine_config=data.engine_config,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_template", "updated", template_id)
|
||||
return AudioTemplateResponse(
|
||||
@@ -161,6 +171,8 @@ async def update_audio_template(
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@@ -23,6 +23,7 @@ from ledgrab.storage.automation import (
|
||||
ApplicationRule,
|
||||
DisplayStateRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
MQTTRule,
|
||||
Rule,
|
||||
StartupRule,
|
||||
@@ -75,6 +76,11 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
|
||||
state=s.state or "",
|
||||
match_mode=s.match_mode or "exact",
|
||||
),
|
||||
"http_poll": lambda: HTTPPollRule(
|
||||
value_source_id=s.value_source_id or "",
|
||||
operator=s.operator or "equals",
|
||||
value=s.value or "",
|
||||
),
|
||||
}
|
||||
factory = _SCHEMA_TO_RULE.get(s.rule_type)
|
||||
if factory is None:
|
||||
@@ -122,6 +128,8 @@ def _automation_to_response(
|
||||
last_activated_at=state.get("last_activated_at"),
|
||||
last_deactivated_at=state.get("last_deactivated_at"),
|
||||
tags=automation.tags,
|
||||
icon=getattr(automation, "icon", "") or "",
|
||||
icon_color=getattr(automation, "icon_color", "") or "",
|
||||
created_at=automation.created_at,
|
||||
updated_at=automation.updated_at,
|
||||
)
|
||||
@@ -191,6 +199,8 @@ async def create_automation(
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
|
||||
if automation.enabled:
|
||||
@@ -285,6 +295,8 @@ async def update_automation(
|
||||
rules=rules,
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
if data.scene_preset_id is not None:
|
||||
update_kwargs["scene_preset_id"] = data.scene_preset_id
|
||||
|
||||
@@ -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,20 @@ async def update_device(
|
||||
ble_govee_key=update_data.ble_govee_key,
|
||||
group_device_ids=update_data.group_device_ids,
|
||||
group_mode=update_data.group_mode,
|
||||
icon=update_data.icon,
|
||||
icon_color=update_data.icon_color,
|
||||
)
|
||||
|
||||
# Sync connection info in processor manager
|
||||
try:
|
||||
manager.update_device_info(
|
||||
device_id,
|
||||
device_url=update_data.url,
|
||||
led_count=update_data.led_count,
|
||||
device_url=normalized_url,
|
||||
led_count=normalized_led_count,
|
||||
baud_rate=update_data.baud_rate,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.debug("Processor manager device update skipped for %s: %s", device_id, e)
|
||||
pass
|
||||
|
||||
# Sync auto_shutdown and zone_mode in runtime state
|
||||
ds = manager.find_device_state(device_id)
|
||||
|
||||
@@ -158,6 +158,8 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
|
||||
updated_at=config.updated_at,
|
||||
description=config.description,
|
||||
tags=config.tags,
|
||||
icon=getattr(config, "icon", "") or "",
|
||||
icon_color=getattr(config, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -255,6 +257,8 @@ async def create_integration(
|
||||
event_mappings=mappings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
|
||||
fire_entity_event("game_integration", "created", config.id)
|
||||
@@ -323,6 +327,8 @@ async def update_integration(
|
||||
event_mappings=mappings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
|
||||
fire_entity_event("game_integration", "updated", integration_id)
|
||||
|
||||
@@ -35,6 +35,8 @@ def _to_response(gradient: Gradient) -> GradientResponse:
|
||||
tags=gradient.tags,
|
||||
created_at=gradient.created_at,
|
||||
updated_at=gradient.updated_at,
|
||||
icon=getattr(gradient, "icon", "") or "",
|
||||
icon_color=getattr(gradient, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +68,8 @@ async def create_gradient(
|
||||
stops=[s.model_dump() for s in data.stops],
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("gradient", "created", gradient.id)
|
||||
return _to_response(gradient)
|
||||
@@ -103,6 +107,8 @@ async def update_gradient(
|
||||
stops=stops,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("gradient", "updated", gradient_id)
|
||||
return _to_response(gradient)
|
||||
|
||||
@@ -55,6 +55,8 @@ def _to_response(
|
||||
entity_count=len(runtime.get_all_states()) if runtime else 0,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
token=token_field,
|
||||
@@ -105,6 +107,8 @@ async def create_ha_source(
|
||||
entity_filters=data.entity_filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -158,6 +162,8 @@ async def update_ha_source(
|
||||
entity_filters=data.entity_filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||
@@ -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")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Depends
|
||||
|
||||
@@ -9,17 +9,27 @@ from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_device_store,
|
||||
get_mqtt_store,
|
||||
get_output_target_store,
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from ledgrab.api.schemas.output_targets import (
|
||||
HALightMappingSchema,
|
||||
HALightOutputTargetCreate,
|
||||
HALightOutputTargetResponse,
|
||||
HALightOutputTargetUpdate,
|
||||
LedOutputTargetCreate,
|
||||
LedOutputTargetResponse,
|
||||
LedOutputTargetUpdate,
|
||||
OutputTargetCreate,
|
||||
OutputTargetListResponse,
|
||||
OutputTargetResponse,
|
||||
OutputTargetUpdate,
|
||||
Z2MLightMappingSchema,
|
||||
Z2MLightOutputTargetCreate,
|
||||
Z2MLightOutputTargetResponse,
|
||||
Z2MLightOutputTargetUpdate,
|
||||
)
|
||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||
from ledgrab.storage import DeviceStore
|
||||
@@ -29,7 +39,13 @@ from ledgrab.storage.ha_light_output_target import (
|
||||
HALightMapping,
|
||||
HALightOutputTarget,
|
||||
)
|
||||
from ledgrab.storage.z2m_light_output_target import (
|
||||
Z2MLightMapping,
|
||||
Z2MLightOutputTarget,
|
||||
)
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
|
||||
@@ -54,6 +70,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
|
||||
protocol=target.protocol,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
icon=getattr(target, "icon", "") or "",
|
||||
icon_color=getattr(target, "icon_color", "") or "",
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
@@ -66,8 +84,11 @@ def _ha_light_target_to_response(
|
||||
return HALightOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
ha_source_id=target.ha_source_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
ha_source_id=target.ha_source_id or "",
|
||||
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
|
||||
# Defensive coalesce — older records stored via resolve_ref may hold None.
|
||||
color_strip_source_id=target.color_strip_source_id or "",
|
||||
color_value_source_id=target.color_value_source_id or "",
|
||||
brightness=target.brightness.to_dict(),
|
||||
ha_light_mappings=[
|
||||
HALightMappingSchema(
|
||||
@@ -82,34 +103,183 @@ def _ha_light_target_to_response(
|
||||
transition=target.transition.to_dict(),
|
||||
color_tolerance=target.color_tolerance.to_dict(),
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
stop_action=target.stop_action,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
icon=getattr(target, "icon", "") or "",
|
||||
icon_color=getattr(target, "icon_color", "") or "",
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _target_to_response(target) -> OutputTargetResponse:
|
||||
"""Convert any OutputTarget to the appropriate typed response."""
|
||||
if isinstance(target, WledOutputTarget):
|
||||
return _led_target_to_response(target)
|
||||
elif isinstance(target, HALightOutputTarget):
|
||||
return _ha_light_target_to_response(target)
|
||||
else:
|
||||
# Fallback for unknown types — use LED response with defaults
|
||||
return LedOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
def _z2m_light_target_to_response(
|
||||
target: Z2MLightOutputTarget,
|
||||
) -> Z2MLightOutputTargetResponse:
|
||||
"""Convert a Z2MLightOutputTarget to Z2MLightOutputTargetResponse."""
|
||||
return Z2MLightOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
mqtt_source_id=target.mqtt_source_id or "",
|
||||
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
|
||||
color_strip_source_id=target.color_strip_source_id or "",
|
||||
color_value_source_id=target.color_value_source_id or "",
|
||||
brightness=target.brightness.to_dict(),
|
||||
z2m_light_mappings=[
|
||||
Z2MLightMappingSchema(
|
||||
friendly_name=m.friendly_name,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=m.brightness_scale.to_dict(),
|
||||
)
|
||||
for m in target.light_mappings
|
||||
],
|
||||
base_topic=target.base_topic,
|
||||
update_rate=target.update_rate.to_dict(),
|
||||
transition=target.transition.to_dict(),
|
||||
color_tolerance=target.color_tolerance.to_dict(),
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
stop_action=target.stop_action if target.stop_action in ("none", "turn_off") else "none",
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
icon=getattr(target, "icon", "") or "",
|
||||
icon_color=getattr(target, "icon_color", "") or "",
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _validate_color_value_source(
|
||||
value_source_store: ValueSourceStore, color_value_source_id: str
|
||||
) -> None:
|
||||
"""Ensure the referenced ValueSource exists and returns colour."""
|
||||
if not color_value_source_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="color_value_source_id is required when source_kind='color_vs'",
|
||||
)
|
||||
try:
|
||||
source = value_source_store.get_source(color_value_source_id)
|
||||
except (ValueError, EntityNotFoundError):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Color value source {color_value_source_id} not found",
|
||||
)
|
||||
if source.to_dict().get("return_type") != "color":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Value source {color_value_source_id} does not return colour "
|
||||
"(return_type must be 'color')"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_TARGET_RESPONSE_BUILDERS: dict = {
|
||||
WledOutputTarget: _led_target_to_response,
|
||||
HALightOutputTarget: _ha_light_target_to_response,
|
||||
Z2MLightOutputTarget: _z2m_light_target_to_response,
|
||||
}
|
||||
|
||||
|
||||
def _assert_target_response_coverage() -> None:
|
||||
"""Verify the response registry covers every concrete OutputTarget subclass.
|
||||
|
||||
Runs at module import. Surfaces a missing builder eagerly instead of
|
||||
letting a request fall through to the previous silent fallback (which
|
||||
used to return a defaults-filled LedOutputTargetResponse and quietly
|
||||
misshape the payload for unknown target types).
|
||||
"""
|
||||
expected = {WledOutputTarget, HALightOutputTarget, Z2MLightOutputTarget}
|
||||
registered = set(_TARGET_RESPONSE_BUILDERS.keys())
|
||||
missing = expected - registered
|
||||
extra = registered - expected
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing builders: {sorted(c.__name__ for c in missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
|
||||
raise RuntimeError(
|
||||
"_TARGET_RESPONSE_BUILDERS is out of sync with the OutputTarget "
|
||||
"subclass set: " + "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_target_response_coverage()
|
||||
|
||||
|
||||
def _target_to_response(target) -> OutputTargetResponse:
|
||||
"""Convert any OutputTarget to the appropriate typed response.
|
||||
|
||||
Dispatches via :data:`_TARGET_RESPONSE_BUILDERS` keyed by concrete
|
||||
subclass. Raises ``RuntimeError`` for an unregistered subclass —
|
||||
coverage is asserted at import, so this should never fire in
|
||||
practice; if it does, the storage layer added a new OutputTarget
|
||||
subclass without a matching response builder here.
|
||||
"""
|
||||
builder = _TARGET_RESPONSE_BUILDERS.get(type(target))
|
||||
if builder is None:
|
||||
raise RuntimeError(
|
||||
f"No response builder registered for OutputTarget subclass " f"{type(target).__name__}"
|
||||
)
|
||||
return builder(target)
|
||||
|
||||
|
||||
# ===== CRUD ENDPOINTS =====
|
||||
|
||||
|
||||
def _build_ha_mappings(
|
||||
payload: list[HALightMappingSchema] | None,
|
||||
) -> list[HALightMapping] | None:
|
||||
if not payload:
|
||||
return None
|
||||
return [
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in payload
|
||||
]
|
||||
|
||||
|
||||
def _build_z2m_mappings(
|
||||
payload: list[Z2MLightMappingSchema] | None,
|
||||
) -> list[Z2MLightMapping] | None:
|
||||
if not payload:
|
||||
return None
|
||||
return [
|
||||
Z2MLightMapping(
|
||||
friendly_name=m.friendly_name,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in payload
|
||||
]
|
||||
|
||||
|
||||
def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
|
||||
if not device_id:
|
||||
return
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
|
||||
def _validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str) -> None:
|
||||
"""Ensure the referenced MQTT source exists. Empty id is allowed (unconfigured)."""
|
||||
if not mqtt_source_id:
|
||||
return
|
||||
try:
|
||||
mqtt_store.get(mqtt_source_id)
|
||||
except (ValueError, EntityNotFoundError):
|
||||
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
|
||||
)
|
||||
@@ -119,53 +289,70 @@ async def create_target(
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
value_source_store: ValueSourceStore = Depends(get_value_source_store),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Create a new output target."""
|
||||
try:
|
||||
# Validate device exists if provided
|
||||
device_id = getattr(data, "device_id", "")
|
||||
if device_id:
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = (
|
||||
[
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
match data:
|
||||
case LedOutputTargetCreate():
|
||||
_validate_device_exists(device_store, data.device_id)
|
||||
target = target_store.create_wled_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness=data.brightness,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
adaptive_fps=data.adaptive_fps,
|
||||
protocol=data.protocol,
|
||||
)
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
if ha_light_mappings_raw
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in store
|
||||
target = target_store.create_target(
|
||||
name=data.name,
|
||||
target_type=data.target_type,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=getattr(data, "color_strip_source_id", ""),
|
||||
brightness=getattr(data, "brightness", 1.0),
|
||||
fps=getattr(data, "fps", 30),
|
||||
keepalive_interval=getattr(data, "keepalive_interval", 1.0),
|
||||
state_check_interval=getattr(data, "state_check_interval", 30),
|
||||
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
|
||||
adaptive_fps=getattr(data, "adaptive_fps", False),
|
||||
protocol=getattr(data, "protocol", "ddp"),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=getattr(data, "ha_source_id", ""),
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=getattr(data, "update_rate", 2.0),
|
||||
transition=getattr(data, "transition", 0.5),
|
||||
color_tolerance=getattr(data, "color_tolerance", 5),
|
||||
)
|
||||
case HALightOutputTargetCreate():
|
||||
if data.source_kind == "color_vs":
|
||||
_validate_color_value_source(value_source_store, data.color_value_source_id)
|
||||
target = target_store.create_ha_light_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=data.ha_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
case Z2MLightOutputTargetCreate():
|
||||
if data.source_kind == "color_vs":
|
||||
_validate_color_value_source(value_source_store, data.color_value_source_id)
|
||||
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
||||
target = target_store.create_z2m_light_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
mqtt_source_id=data.mqtt_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
|
||||
base_topic=data.base_topic,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
|
||||
raise HTTPException(status_code=400, detail="Unknown target_type")
|
||||
|
||||
# Register in processor manager
|
||||
try:
|
||||
@@ -233,6 +420,18 @@ async def get_target(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
def _resolve_effective_color_vs_id(
|
||||
target_store: OutputTargetStore, target_id: str, payload_id: Optional[str]
|
||||
) -> str:
|
||||
if payload_id is not None:
|
||||
return payload_id
|
||||
try:
|
||||
existing = target_store.get_target(target_id)
|
||||
except ValueError:
|
||||
return ""
|
||||
return getattr(existing, "color_value_source_id", "") or ""
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
|
||||
)
|
||||
@@ -243,90 +442,161 @@ async def update_target(
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
value_source_store: ValueSourceStore = Depends(get_value_source_store),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Update a output target."""
|
||||
try:
|
||||
# Validate device exists if changing
|
||||
device_id = getattr(data, "device_id", None)
|
||||
if device_id is not None and device_id:
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
css_changed = False
|
||||
brightness_changed = False
|
||||
settings_changed = False
|
||||
device_changed = False
|
||||
|
||||
# Build HA light mappings if provided
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = None
|
||||
if ha_light_mappings_raw is not None:
|
||||
ha_mappings = [
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
match data:
|
||||
case LedOutputTargetUpdate():
|
||||
if data.device_id:
|
||||
_validate_device_exists(device_store, data.device_id)
|
||||
target = target_store.update_wled_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness=data.brightness,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
adaptive_fps=data.adaptive_fps,
|
||||
protocol=data.protocol,
|
||||
)
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
|
||||
# Update in store
|
||||
target = target_store.update_target(
|
||||
target_id=target_id,
|
||||
name=data.name,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=getattr(data, "color_strip_source_id", None),
|
||||
brightness=getattr(data, "brightness", None),
|
||||
fps=getattr(data, "fps", None),
|
||||
keepalive_interval=getattr(data, "keepalive_interval", None),
|
||||
state_check_interval=getattr(data, "state_check_interval", None),
|
||||
min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
|
||||
adaptive_fps=getattr(data, "adaptive_fps", None),
|
||||
protocol=getattr(data, "protocol", None),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=getattr(data, "ha_source_id", None),
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=getattr(data, "update_rate", None),
|
||||
transition=getattr(data, "transition", None),
|
||||
color_tolerance=getattr(data, "color_tolerance", None),
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.fps,
|
||||
data.keepalive_interval,
|
||||
data.state_check_interval,
|
||||
data.min_brightness_threshold,
|
||||
data.adaptive_fps,
|
||||
data.brightness,
|
||||
)
|
||||
)
|
||||
device_changed = data.device_id is not None
|
||||
case HALightOutputTargetUpdate():
|
||||
# Validate color VS when switching into / staying in color_vs mode
|
||||
if data.source_kind == "color_vs" or (
|
||||
data.source_kind is None and data.color_value_source_id
|
||||
):
|
||||
effective_id = _resolve_effective_color_vs_id(
|
||||
target_store, target_id, data.color_value_source_id
|
||||
)
|
||||
_validate_color_value_source(value_source_store, effective_id)
|
||||
target = target_store.update_ha_light_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
ha_source_id=data.ha_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.source_kind,
|
||||
data.color_value_source_id,
|
||||
data.brightness,
|
||||
data.update_rate,
|
||||
data.transition,
|
||||
data.min_brightness_threshold,
|
||||
data.color_tolerance,
|
||||
data.ha_light_mappings,
|
||||
data.stop_action,
|
||||
)
|
||||
)
|
||||
case Z2MLightOutputTargetUpdate():
|
||||
if data.source_kind == "color_vs" or (
|
||||
data.source_kind is None and data.color_value_source_id
|
||||
):
|
||||
effective_id = _resolve_effective_color_vs_id(
|
||||
target_store, target_id, data.color_value_source_id
|
||||
)
|
||||
_validate_color_value_source(value_source_store, effective_id)
|
||||
if data.mqtt_source_id:
|
||||
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
||||
target = target_store.update_z2m_light_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
mqtt_source_id=data.mqtt_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
|
||||
base_topic=data.base_topic,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.source_kind,
|
||||
data.color_value_source_id,
|
||||
data.mqtt_source_id,
|
||||
data.brightness,
|
||||
data.base_topic,
|
||||
data.update_rate,
|
||||
data.transition,
|
||||
data.min_brightness_threshold,
|
||||
data.color_tolerance,
|
||||
data.z2m_light_mappings,
|
||||
data.stop_action,
|
||||
)
|
||||
)
|
||||
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
|
||||
raise HTTPException(status_code=400, detail="Unknown target_type")
|
||||
|
||||
# Sync processor manager (run in thread — css release/acquire can block)
|
||||
color_strip_source_id = getattr(data, "color_strip_source_id", None)
|
||||
fps = getattr(data, "fps", None)
|
||||
keepalive_interval = getattr(data, "keepalive_interval", None)
|
||||
state_check_interval = getattr(data, "state_check_interval", None)
|
||||
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
|
||||
adaptive_fps = getattr(data, "adaptive_fps", None)
|
||||
update_rate = getattr(data, "update_rate", None)
|
||||
transition = getattr(data, "transition", None)
|
||||
color_tolerance = getattr(data, "color_tolerance", None)
|
||||
brightness = getattr(data, "brightness", None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
target.sync_with_manager,
|
||||
manager,
|
||||
settings_changed=(
|
||||
fps is not None
|
||||
or keepalive_interval is not None
|
||||
or state_check_interval is not None
|
||||
or min_brightness_threshold is not None
|
||||
or adaptive_fps is not None
|
||||
or update_rate is not None
|
||||
or transition is not None
|
||||
or color_tolerance is not None
|
||||
or ha_light_mappings_raw is not None
|
||||
or brightness is not None
|
||||
),
|
||||
css_changed=color_strip_source_id is not None,
|
||||
brightness_changed=brightness is not None,
|
||||
settings_changed=settings_changed,
|
||||
css_changed=css_changed,
|
||||
brightness_changed=brightness_changed,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
|
||||
pass
|
||||
|
||||
# Device change requires async stop -> swap -> start cycle
|
||||
if device_id is not None:
|
||||
# LED-only: device change requires async stop -> swap -> start cycle
|
||||
if device_changed and isinstance(target, WledOutputTarget):
|
||||
try:
|
||||
await manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -335,6 +335,35 @@ async def get_overlay_status(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
# ===== HA LIGHT — MANUAL TURN OFF =====
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/ha-light/turn-off", tags=["Processing"])
|
||||
async def turn_off_ha_light_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Turn off all HA light entities mapped by the target.
|
||||
|
||||
Works regardless of whether the target's processor is running. Useful
|
||||
when ``stop_action`` is ``"none"`` and lights were left on after a stop.
|
||||
"""
|
||||
try:
|
||||
# Verify target exists
|
||||
target_store.get_target(target_id)
|
||||
count = await manager.turn_off_ha_light_target(target_id)
|
||||
return {"status": "ok", "target_id": target_id, "entities": count}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to turn off HA lights: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== HA LIGHT COLOR PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
@@ -377,6 +406,75 @@ async def ha_light_colors_ws(
|
||||
manager.remove_ha_light_ws_client(target_id, websocket)
|
||||
|
||||
|
||||
# ===== Z2M LIGHT — MANUAL TURN OFF =====
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/z2m-light/turn-off", tags=["Processing"])
|
||||
async def turn_off_z2m_light_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Publish OFF to all Z2M bulbs mapped by the target.
|
||||
|
||||
Works regardless of whether the target's processor is running. Useful
|
||||
when ``stop_action`` is ``"none"`` and bulbs were left on after a stop.
|
||||
"""
|
||||
try:
|
||||
target_store.get_target(target_id)
|
||||
count = await manager.turn_off_z2m_light_target(target_id)
|
||||
return {"status": "ok", "target_id": target_id, "entities": count}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to turn off Z2M lights: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== Z2M LIGHT COLOR PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/z2m-light/ws")
|
||||
async def z2m_light_colors_ws(
|
||||
websocket: WebSocket,
|
||||
target_id: str,
|
||||
):
|
||||
"""WebSocket for live Z2M bulb colour preview.
|
||||
|
||||
Streams: {"type":"colors_update","colors":{friendly_name:{r,g,b,hex},...}}
|
||||
at the target's update_rate. Auth via first-message handshake.
|
||||
"""
|
||||
from ledgrab.api.auth import accept_and_authenticate_ws
|
||||
|
||||
if await accept_and_authenticate_ws(websocket) is None:
|
||||
return
|
||||
|
||||
manager: ProcessorManager = get_processor_manager()
|
||||
|
||||
try:
|
||||
proc = manager._processors.get(target_id)
|
||||
if not proc or not proc.is_running:
|
||||
await websocket.close(code=4003, reason="Target not running")
|
||||
return
|
||||
except Exception as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
try:
|
||||
manager.add_z2m_light_ws_client(target_id, websocket)
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except (RuntimeError, ConnectionError) as e:
|
||||
logger.debug("ws closed in z2m-light client: %s", e)
|
||||
finally:
|
||||
manager.remove_z2m_light_ws_client(target_id, websocket)
|
||||
|
||||
|
||||
# ===== LED PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -83,6 +85,8 @@ async def create_pattern_template(
|
||||
rectangles=rectangles,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("pattern_template", "created", template.id)
|
||||
return _pat_template_to_response(template)
|
||||
@@ -139,6 +143,8 @@ async def update_pattern_template(
|
||||
rectangles=rectangles,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("pattern_template", "updated", template_id)
|
||||
return _pat_template_to_response(template)
|
||||
|
||||
@@ -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,6 +7,7 @@ import asyncio
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -24,6 +25,7 @@ from ledgrab.api.dependencies import (
|
||||
get_device_store,
|
||||
get_ha_manager,
|
||||
get_ha_store,
|
||||
get_mqtt_manager,
|
||||
get_output_target_store,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
@@ -92,6 +94,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 +131,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -316,6 +326,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 +347,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 +381,20 @@ async def get_integrations_status(
|
||||
_: AuthRequired,
|
||||
ha_store=Depends(get_ha_store),
|
||||
ha_manager=Depends(get_ha_manager),
|
||||
mqtt_manager=Depends(get_mqtt_manager),
|
||||
):
|
||||
"""Return connection status for external integrations (MQTT, Home Assistant).
|
||||
|
||||
Used by the dashboard to show connectivity indicators.
|
||||
Used by the dashboard to show connectivity indicators. MQTT is reported
|
||||
per-source since the multi-broker refactor — no more global "MQTT
|
||||
enabled" flag.
|
||||
"""
|
||||
from ledgrab.core.devices.mqtt_client import get_mqtt_service
|
||||
|
||||
# MQTT status
|
||||
mqtt_service = get_mqtt_service()
|
||||
mqtt_config = get_config().mqtt
|
||||
# MQTT status — one entry per configured source
|
||||
mqtt_items = mqtt_manager.get_all_sources_status()
|
||||
mqtt_status = {
|
||||
"enabled": mqtt_config.enabled,
|
||||
"connected": mqtt_service.is_connected if mqtt_service else False,
|
||||
"broker": (
|
||||
f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None
|
||||
),
|
||||
"sources": mqtt_items,
|
||||
"total": len(mqtt_items),
|
||||
"connected": sum(1 for s in mqtt_items if s.get("connected")),
|
||||
}
|
||||
|
||||
# Home Assistant status
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -12,6 +12,16 @@ class AssetUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name")
|
||||
description: Optional[str] = Field(None, max_length=500, description="Optional description")
|
||||
tags: Optional[List[str]] = Field(None, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
@@ -26,6 +36,16 @@ class AssetResponse(BaseModel):
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -17,6 +17,16 @@ class AudioProcessingTemplateCreate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AudioProcessingTemplateUpdate(BaseModel):
|
||||
@@ -28,6 +38,16 @@ class AudioProcessingTemplateUpdate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AudioProcessingTemplateResponse(BaseModel):
|
||||
@@ -42,6 +62,16 @@ class AudioProcessingTemplateResponse(BaseModel):
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AudioProcessingTemplateListResponse(BaseModel):
|
||||
|
||||
@@ -19,6 +19,16 @@ class _AudioSourceResponseBase(BaseModel):
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class CaptureAudioSourceResponse(_AudioSourceResponseBase):
|
||||
@@ -53,6 +63,16 @@ class _AudioSourceCreateBase(BaseModel):
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class CaptureAudioSourceCreate(_AudioSourceCreateBase):
|
||||
@@ -87,6 +107,16 @@ class _AudioSourceUpdateBase(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
|
||||
|
||||
@@ -16,6 +16,16 @@ class AudioTemplateCreate(BaseModel):
|
||||
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AudioTemplateUpdate(BaseModel):
|
||||
@@ -26,6 +36,16 @@ class AudioTemplateUpdate(BaseModel):
|
||||
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AudioTemplateResponse(BaseModel):
|
||||
@@ -39,6 +59,16 @@ class AudioTemplateResponse(BaseModel):
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AudioTemplateListResponse(BaseModel):
|
||||
|
||||
@@ -46,6 +46,24 @@ class RuleSchema(BaseModel):
|
||||
None,
|
||||
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
|
||||
)
|
||||
# HTTP poll rule fields
|
||||
value_source_id: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Value source ID (for http_poll rule). The referenced "
|
||||
"ValueSource must be of source_type='http'."
|
||||
),
|
||||
)
|
||||
operator: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Comparison operator for http_poll rule: "
|
||||
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
|
||||
),
|
||||
)
|
||||
value: Optional[str] = Field(
|
||||
None, description="Expected value (for http_poll rule; ignored for 'exists')"
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatible alias
|
||||
@@ -67,6 +85,16 @@ class AutomationCreate(BaseModel):
|
||||
None, description="Scene preset for fallback deactivation"
|
||||
)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AutomationUpdate(BaseModel):
|
||||
@@ -84,6 +112,16 @@ class AutomationUpdate(BaseModel):
|
||||
None, description="Scene preset for fallback deactivation"
|
||||
)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class AutomationResponse(BaseModel):
|
||||
@@ -108,6 +146,16 @@ class AutomationResponse(BaseModel):
|
||||
last_deactivated_at: Optional[datetime] = Field(
|
||||
None, description="Last time this automation was deactivated"
|
||||
)
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -17,6 +17,16 @@ class ColorStripProcessingTemplateCreate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateUpdate(BaseModel):
|
||||
@@ -28,6 +38,16 @@ class ColorStripProcessingTemplateUpdate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateResponse(BaseModel):
|
||||
@@ -40,6 +60,16 @@ class ColorStripProcessingTemplateResponse(BaseModel):
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateListResponse(BaseModel):
|
||||
|
||||
@@ -28,7 +28,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)")
|
||||
|
||||
|
||||
@@ -95,6 +95,16 @@ class _CSSResponseBase(BaseModel):
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PictureCSSResponse(_CSSResponseBase):
|
||||
@@ -112,9 +122,9 @@ class PictureAdvancedCSSResponse(_CSSResponseBase):
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class StaticCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
color: Any = Field(description="Static RGB color")
|
||||
class SingleColorCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["single_color"] = "single_color"
|
||||
color: Any = Field(description="Solid RGB color")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
|
||||
|
||||
@@ -126,11 +136,6 @@ class GradientCSSResponse(_CSSResponseBase):
|
||||
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")
|
||||
|
||||
|
||||
class EffectCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["effect"] = "effect"
|
||||
effect_type: str = Field(description="Effect algorithm")
|
||||
@@ -235,13 +240,19 @@ class MathWaveCSSResponse(_CSSResponseBase):
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
|
||||
|
||||
|
||||
class GameEventCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["game_event"] = "game_event"
|
||||
game_integration_id: str = Field(description="Game integration entity ID")
|
||||
idle_color: Any = Field(description="Idle RGB color (bindable)")
|
||||
event_mappings: List[dict] = Field(default_factory=list, description="Event-to-effect mappings")
|
||||
|
||||
|
||||
ColorStripSourceResponse = Annotated[
|
||||
Union[
|
||||
Annotated[PictureCSSResponse, Tag("picture")],
|
||||
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSResponse, Tag("static")],
|
||||
Annotated[SingleColorCSSResponse, Tag("single_color")],
|
||||
Annotated[GradientCSSResponse, Tag("gradient")],
|
||||
Annotated[ColorCycleCSSResponse, Tag("color_cycle")],
|
||||
Annotated[EffectCSSResponse, Tag("effect")],
|
||||
Annotated[CompositeCSSResponse, Tag("composite")],
|
||||
Annotated[MappedCSSResponse, Tag("mapped")],
|
||||
@@ -254,6 +265,7 @@ ColorStripSourceResponse = Annotated[
|
||||
Annotated[WeatherCSSResponse, Tag("weather")],
|
||||
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
|
||||
Annotated[MathWaveCSSResponse, Tag("math_wave")],
|
||||
Annotated[GameEventCSSResponse, Tag("game_event")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
@@ -272,6 +284,16 @@ class _CSSCreateBase(BaseModel):
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PictureCSSCreate(_CSSCreateBase):
|
||||
@@ -289,9 +311,9 @@ class PictureAdvancedCSSCreate(_CSSCreateBase):
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class StaticCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
color: Any = Field(default=None, description="Static RGB color [R,G,B]")
|
||||
class SingleColorCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["single_color"] = "single_color"
|
||||
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
|
||||
|
||||
@@ -303,11 +325,6 @@ class GradientCSSCreate(_CSSCreateBase):
|
||||
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")
|
||||
|
||||
|
||||
class EffectCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["effect"] = "effect"
|
||||
effect_type: Optional[str] = Field(None, description="Effect algorithm")
|
||||
@@ -425,13 +442,19 @@ class MathWaveCSSCreate(_CSSCreateBase):
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
|
||||
|
||||
|
||||
class GameEventCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["game_event"] = "game_event"
|
||||
game_integration_id: Optional[str] = Field(None, description="Game integration entity ID")
|
||||
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
|
||||
event_mappings: Optional[List[dict]] = Field(None, description="Event-to-effect mappings")
|
||||
|
||||
|
||||
ColorStripSourceCreate = Annotated[
|
||||
Union[
|
||||
Annotated[PictureCSSCreate, Tag("picture")],
|
||||
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSCreate, Tag("static")],
|
||||
Annotated[SingleColorCSSCreate, Tag("single_color")],
|
||||
Annotated[GradientCSSCreate, Tag("gradient")],
|
||||
Annotated[ColorCycleCSSCreate, Tag("color_cycle")],
|
||||
Annotated[EffectCSSCreate, Tag("effect")],
|
||||
Annotated[CompositeCSSCreate, Tag("composite")],
|
||||
Annotated[MappedCSSCreate, Tag("mapped")],
|
||||
@@ -444,6 +467,7 @@ ColorStripSourceCreate = Annotated[
|
||||
Annotated[WeatherCSSCreate, Tag("weather")],
|
||||
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
|
||||
Annotated[MathWaveCSSCreate, Tag("math_wave")],
|
||||
Annotated[GameEventCSSCreate, Tag("game_event")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
@@ -462,6 +486,16 @@ class _CSSUpdateBase(BaseModel):
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PictureCSSUpdate(_CSSUpdateBase):
|
||||
@@ -479,9 +513,9 @@ class PictureAdvancedCSSUpdate(_CSSUpdateBase):
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class StaticCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
color: Any = Field(default=None, description="Static RGB color [R,G,B]")
|
||||
class SingleColorCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["single_color"] = "single_color"
|
||||
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
|
||||
|
||||
|
||||
@@ -493,11 +527,6 @@ class GradientCSSUpdate(_CSSUpdateBase):
|
||||
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")
|
||||
|
||||
|
||||
class EffectCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["effect"] = "effect"
|
||||
effect_type: Optional[str] = Field(None, description="Effect algorithm")
|
||||
@@ -613,13 +642,19 @@ class MathWaveCSSUpdate(_CSSUpdateBase):
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
|
||||
|
||||
|
||||
class GameEventCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["game_event"] = "game_event"
|
||||
game_integration_id: Optional[str] = Field(None, description="Game integration entity ID")
|
||||
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
|
||||
event_mappings: Optional[List[dict]] = Field(None, description="Event-to-effect mappings")
|
||||
|
||||
|
||||
ColorStripSourceUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[PictureCSSUpdate, Tag("picture")],
|
||||
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSUpdate, Tag("static")],
|
||||
Annotated[SingleColorCSSUpdate, Tag("single_color")],
|
||||
Annotated[GradientCSSUpdate, Tag("gradient")],
|
||||
Annotated[ColorCycleCSSUpdate, Tag("color_cycle")],
|
||||
Annotated[EffectCSSUpdate, Tag("effect")],
|
||||
Annotated[CompositeCSSUpdate, Tag("composite")],
|
||||
Annotated[MappedCSSUpdate, Tag("mapped")],
|
||||
@@ -632,6 +667,7 @@ ColorStripSourceUpdate = Annotated[
|
||||
Annotated[WeatherCSSUpdate, Tag("weather")],
|
||||
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
|
||||
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
|
||||
Annotated[GameEventCSSUpdate, Tag("game_event")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
@@ -655,10 +691,22 @@ 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: Optional[int] = Field(
|
||||
None, ge=0, description="Starting LED index (default 0 = beginning of strip)"
|
||||
)
|
||||
length: Optional[int] = 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(
|
||||
|
||||
@@ -37,6 +37,19 @@ class DeviceCreate(BaseModel):
|
||||
dmx_start_channel: Optional[int] = Field(
|
||||
None, ge=1, le=512, description="DMX start channel (1-512)"
|
||||
)
|
||||
# DDP fields
|
||||
ddp_port: Optional[int] = Field(
|
||||
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
|
||||
)
|
||||
ddp_destination_id: Optional[int] = Field(
|
||||
None, ge=0, le=255, description="DDP destination ID (default 1 = display)"
|
||||
)
|
||||
ddp_color_order: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=5,
|
||||
description="DDP color order: 0=GRB 1=RGB 2=BRG 3=RBG 4=BGR 5=GBR (most receivers expect RGB)",
|
||||
)
|
||||
# ESP-NOW fields
|
||||
espnow_peer_mac: Optional[str] = Field(
|
||||
None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)"
|
||||
@@ -50,6 +63,53 @@ class DeviceCreate(BaseModel):
|
||||
hue_entertainment_group_id: Optional[str] = Field(
|
||||
None, description="Hue entertainment group/zone ID"
|
||||
)
|
||||
# Yeelight fields
|
||||
yeelight_min_interval_ms: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=10000,
|
||||
description="Yeelight client-side rate limit between commands in ms (default 500)",
|
||||
)
|
||||
# WiZ fields
|
||||
wiz_min_interval_ms: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=10000,
|
||||
description="WiZ client-side rate limit between commands in ms (default 50)",
|
||||
)
|
||||
# LIFX fields
|
||||
lifx_min_interval_ms: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=10000,
|
||||
description="LIFX client-side rate limit between commands in ms (default 50)",
|
||||
)
|
||||
# Govee fields
|
||||
govee_min_interval_ms: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=10000,
|
||||
description="Govee client-side rate limit between commands in ms (default 50)",
|
||||
)
|
||||
# OPC fields
|
||||
opc_channel: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=255,
|
||||
description="OPC channel (0 = broadcast to all channels on the server)",
|
||||
)
|
||||
# Nanoleaf fields
|
||||
nanoleaf_token: Optional[str] = Field(
|
||||
None,
|
||||
max_length=512,
|
||||
description="Nanoleaf auth token returned by the pairing handshake",
|
||||
)
|
||||
nanoleaf_min_interval_ms: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=10000,
|
||||
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
|
||||
)
|
||||
# SPI Direct fields
|
||||
spi_speed_hz: Optional[int] = Field(
|
||||
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
|
||||
@@ -86,6 +146,17 @@ class DeviceCreate(BaseModel):
|
||||
None,
|
||||
description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)",
|
||||
)
|
||||
# Custom card icon (frontend display only)
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the card's channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
@@ -115,6 +186,11 @@ class DeviceUpdate(BaseModel):
|
||||
dmx_start_channel: Optional[int] = Field(
|
||||
None, ge=1, le=512, description="DMX start channel (1-512)"
|
||||
)
|
||||
ddp_port: Optional[int] = Field(
|
||||
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
|
||||
)
|
||||
ddp_destination_id: Optional[int] = Field(None, ge=0, le=255, description="DDP destination ID")
|
||||
ddp_color_order: Optional[int] = Field(None, ge=0, le=5, description="DDP color order code")
|
||||
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address")
|
||||
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
|
||||
hue_username: Optional[str] = Field(None, description="Hue bridge username")
|
||||
@@ -122,6 +198,25 @@ class DeviceUpdate(BaseModel):
|
||||
hue_entertainment_group_id: Optional[str] = Field(
|
||||
None, description="Hue entertainment group ID"
|
||||
)
|
||||
yeelight_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
|
||||
)
|
||||
wiz_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="WiZ client-side rate limit in ms"
|
||||
)
|
||||
lifx_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
|
||||
)
|
||||
govee_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
|
||||
)
|
||||
opc_channel: Optional[int] = Field(
|
||||
None, ge=0, le=255, description="OPC channel (0 = broadcast)"
|
||||
)
|
||||
nanoleaf_token: Optional[str] = Field(None, max_length=512, description="Nanoleaf auth token")
|
||||
nanoleaf_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
|
||||
)
|
||||
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
|
||||
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
|
||||
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
|
||||
@@ -140,6 +235,43 @@ class DeviceUpdate(BaseModel):
|
||||
None, description="Ordered list of child device IDs (for group device type)"
|
||||
)
|
||||
group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent")
|
||||
# Custom card icon
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PairDeviceRequest(BaseModel):
|
||||
"""Initiate a pairing handshake with a device before creating it.
|
||||
|
||||
The caller is expected to have just performed the device's physical
|
||||
pairing action (e.g. holding the power button on a Nanoleaf for 5 s,
|
||||
pressing the Hue bridge link button). The response carries any
|
||||
provider-specific fields the frontend must include in the subsequent
|
||||
``POST /api/v1/devices`` payload — typically an auth token.
|
||||
"""
|
||||
|
||||
device_type: str = Field(description="Device type identifier (e.g. 'nanoleaf')")
|
||||
url: str = Field(description="Device URL (e.g. 'nanoleaf://192.168.1.50')")
|
||||
|
||||
|
||||
class PairDeviceResponse(BaseModel):
|
||||
"""Successful pairing result. ``fields`` is merged into the create payload."""
|
||||
|
||||
fields: Dict[str, object] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"Provider-specific fields to include in the subsequent device-create "
|
||||
"request (e.g. {'nanoleaf_token': 'abc...'})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CalibrationLineSchema(BaseModel):
|
||||
@@ -272,11 +404,38 @@ class DeviceResponse(BaseModel):
|
||||
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
|
||||
dmx_start_universe: int = Field(default=0, description="DMX start universe")
|
||||
dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)")
|
||||
ddp_port: int = Field(default=0, description="DDP UDP port (0 = protocol default 4048)")
|
||||
ddp_destination_id: int = Field(default=1, description="DDP destination ID")
|
||||
ddp_color_order: int = Field(default=1, description="DDP color order code (1 = RGB)")
|
||||
espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address")
|
||||
espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel")
|
||||
hue_username: str = Field(default="", description="Hue bridge username")
|
||||
hue_client_key: str = Field(default="", description="Hue entertainment client key")
|
||||
hue_paired: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Whether the Hue bridge has been paired (i.e. a username/client_key "
|
||||
"is on file). The actual credentials are intentionally not exposed "
|
||||
"in the response -- to re-pair, delete and re-add the device."
|
||||
),
|
||||
)
|
||||
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
|
||||
yeelight_min_interval_ms: int = Field(
|
||||
default=500, description="Yeelight client-side rate limit in ms"
|
||||
)
|
||||
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
|
||||
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
|
||||
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
|
||||
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
|
||||
nanoleaf_paired: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Whether the Nanoleaf auth token has been issued by the pairing "
|
||||
"handshake. The token itself is intentionally not exposed in the "
|
||||
"response -- to re-pair, delete and re-add the device."
|
||||
),
|
||||
)
|
||||
nanoleaf_min_interval_ms: int = Field(
|
||||
default=100, description="Nanoleaf client-side rate limit in ms"
|
||||
)
|
||||
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
|
||||
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||
@@ -295,6 +454,8 @@ class DeviceResponse(BaseModel):
|
||||
default_factory=list, description="Ordered list of child device IDs (for group device type)"
|
||||
)
|
||||
group_mode: str = Field(default="sequence", description="Group mode: sequence or independent")
|
||||
icon: str = Field(default="", description="Icon id from the curated icon library")
|
||||
icon_color: str = Field(default="", description="Optional CSS color override for the icon")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -42,6 +42,16 @@ class GameIntegrationCreate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Integration description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class GameIntegrationUpdate(BaseModel):
|
||||
@@ -56,6 +66,16 @@ class GameIntegrationUpdate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Integration description", max_length=500)
|
||||
tags: Optional[List[str]] = Field(None, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class GameIntegrationResponse(BaseModel):
|
||||
@@ -71,6 +91,16 @@ class GameIntegrationResponse(BaseModel):
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Integration description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon.",
|
||||
)
|
||||
|
||||
|
||||
class GameIntegrationListResponse(BaseModel):
|
||||
|
||||
@@ -20,6 +20,16 @@ class GradientCreate(BaseModel):
|
||||
stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class GradientUpdate(BaseModel):
|
||||
@@ -29,6 +39,16 @@ class GradientUpdate(BaseModel):
|
||||
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class GradientResponse(BaseModel):
|
||||
@@ -42,6 +62,16 @@ class GradientResponse(BaseModel):
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class GradientListResponse(BaseModel):
|
||||
|
||||
@@ -18,6 +18,16 @@ class HomeAssistantSourceCreate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantSourceUpdate(BaseModel):
|
||||
@@ -30,6 +40,16 @@ class HomeAssistantSourceUpdate(BaseModel):
|
||||
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantSourceResponse(BaseModel):
|
||||
@@ -44,6 +64,16 @@ class HomeAssistantSourceResponse(BaseModel):
|
||||
entity_count: int = Field(default=0, description="Number of cached entities")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon.",
|
||||
)
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
token: Optional[str] = Field(
|
||||
@@ -94,6 +124,7 @@ class HomeAssistantConnectionStatus(BaseModel):
|
||||
name: str
|
||||
connected: bool
|
||||
entity_count: int
|
||||
host: str = ""
|
||||
|
||||
|
||||
class HomeAssistantStatusResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""HTTP endpoint schemas (CRUD + one-shot test)."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# RFC 7230 token chars for header names + reject any control character in values.
|
||||
_HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
|
||||
_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def _validate_headers(value: Dict[str, str]) -> Dict[str, str]:
|
||||
"""Reject header names/values that could enable CRLF injection."""
|
||||
if value is None:
|
||||
return {}
|
||||
cleaned: Dict[str, str] = {}
|
||||
for name, raw in value.items():
|
||||
if not isinstance(name, str) or not isinstance(raw, str):
|
||||
raise ValueError(f"Header name/value must be strings: {name!r}")
|
||||
if not _HEADER_NAME_RE.match(name):
|
||||
raise ValueError(f"Invalid HTTP header name: {name!r}")
|
||||
if _HEADER_CONTROL_CHARS_RE.search(raw):
|
||||
raise ValueError(
|
||||
f"Invalid HTTP header value for {name!r} (contains control characters)"
|
||||
)
|
||||
cleaned[name] = raw
|
||||
return cleaned
|
||||
|
||||
|
||||
def _validate_url(value: str) -> str:
|
||||
"""Reject URLs that embed ``user:pass@`` so credentials can't leak
|
||||
into server logs (e.g. via the plaintext-token warning helper).
|
||||
|
||||
Schemes + IP-block checks are enforced later by
|
||||
:func:`ledgrab.utils.safe_source.validate_polling_url`.
|
||||
"""
|
||||
if not value:
|
||||
return value
|
||||
parsed = urlparse(value)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError(
|
||||
"URL must not embed credentials (user:pass@). "
|
||||
"Use the auth_token field or a custom Authorization header instead."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class HTTPEndpointCreate(BaseModel):
|
||||
"""Request to create an HTTP endpoint."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
url: str = Field(min_length=1, description="http or https URL")
|
||||
method: Literal["GET", "HEAD"] = "GET"
|
||||
auth_token: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Optional bearer token — sent as 'Authorization: Bearer <token>'. "
|
||||
"Add a custom Authorization entry in `headers` to override."
|
||||
),
|
||||
)
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float = Field(default=10.0, gt=0)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
icon: Optional[str] = Field(None, max_length=64)
|
||||
icon_color: Optional[str] = Field(None, max_length=32)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPEndpointUpdate(BaseModel):
|
||||
"""Request to update an HTTP endpoint.
|
||||
|
||||
All fields optional — ``None`` keeps the existing value. Sending an
|
||||
empty string for ``auth_token`` CLEARS the stored token; omit the
|
||||
field (or send ``null``) to keep it.
|
||||
"""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
url: Optional[str] = Field(None, min_length=1)
|
||||
method: Optional[Literal["GET", "HEAD"]] = None
|
||||
auth_token: Optional[str] = Field(None, description="null = keep existing; '' = clear.")
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
timeout_s: Optional[float] = Field(None, gt=0)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(None, max_length=64)
|
||||
icon_color: Optional[str] = Field(None, max_length=32)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPEndpointResponse(BaseModel):
|
||||
"""HTTP endpoint response. Note: ``auth_token`` is NEVER returned —
|
||||
use ``auth_token_set`` to know whether one is configured."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
method: str
|
||||
auth_token_set: bool = False
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
icon: Optional[str] = Field(None, max_length=64)
|
||||
icon_color: Optional[str] = Field(None, max_length=32)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HTTPEndpointListResponse(BaseModel):
|
||||
endpoints: List[HTTPEndpointResponse]
|
||||
count: int
|
||||
|
||||
|
||||
class HTTPTestRequest(BaseModel):
|
||||
"""One-shot test request to validate URL + auth before saving."""
|
||||
|
||||
url: str
|
||||
method: Literal["GET", "HEAD"] = "GET"
|
||||
auth_token: str = ""
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float = Field(default=10.0, gt=0)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPTestResponse(BaseModel):
|
||||
success: bool
|
||||
status_code: Optional[int] = None
|
||||
body_preview: Optional[str] = Field(None, description="First 500 chars of the body")
|
||||
body_json: Any = None
|
||||
error: Optional[str] = None
|
||||
@@ -18,6 +18,16 @@ class MQTTSourceCreate(BaseModel):
|
||||
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class MQTTSourceUpdate(BaseModel):
|
||||
@@ -32,6 +42,16 @@ class MQTTSourceUpdate(BaseModel):
|
||||
base_topic: Optional[str] = Field(None, description="Base topic prefix")
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class MQTTSourceResponse(BaseModel):
|
||||
@@ -48,6 +68,16 @@ class MQTTSourceResponse(BaseModel):
|
||||
connected: bool = Field(default=False, description="Whether the broker connection is active")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon.",
|
||||
)
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -43,6 +43,20 @@ class HALightMappingSchema(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightMappingSchema(BaseModel):
|
||||
"""Maps an LED range to one Zigbee2MQTT bulb (by friendly name)."""
|
||||
|
||||
friendly_name: str = Field(
|
||||
description="Z2M friendly_name (e.g. 'living_room_bulb_1')",
|
||||
min_length=1,
|
||||
)
|
||||
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
|
||||
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
|
||||
brightness_scale: Optional[BindableFloatInput] = Field(
|
||||
default=1.0, description="Brightness multiplier (bindable)"
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Response schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
@@ -55,6 +69,8 @@ class _OutputTargetResponseBase(BaseModel):
|
||||
name: str = Field(description="Target name")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: str = Field(default="", description="Custom icon id from the curated icon library")
|
||||
icon_color: str = Field(default="", description="Optional CSS color override for the icon")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
@@ -81,7 +97,19 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
|
||||
class HALightOutputTargetResponse(_OutputTargetResponseBase):
|
||||
target_type: Literal["ha_light"] = "ha_light"
|
||||
ha_source_id: str = Field(default="", description="Home Assistant source ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
source_kind: Literal["css", "color_vs"] = Field(
|
||||
default="css",
|
||||
description="Colour source kind: 'css' (per-mapping LED segments) or "
|
||||
"'color_vs' (single colour value source applied to all entities).",
|
||||
)
|
||||
color_strip_source_id: str = Field(
|
||||
default="", description="Color strip source ID (used when source_kind='css')"
|
||||
)
|
||||
color_value_source_id: str = Field(
|
||||
default="",
|
||||
description="Colour value source ID (used when source_kind='color_vs'); "
|
||||
"must reference a value source whose return_type='color'.",
|
||||
)
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
None, description="LED-to-light mappings"
|
||||
@@ -98,12 +126,63 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
default=0, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
stop_action: Literal["none", "turn_off", "restore"] = Field(
|
||||
default="none",
|
||||
description="What to do with mapped lights when the target stops: "
|
||||
"'none' (leave as-is), 'turn_off', or 'restore' (revert to state captured at start).",
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
|
||||
target_type: Literal["z2m_light"] = "z2m_light"
|
||||
mqtt_source_id: str = Field(
|
||||
default="",
|
||||
description="MQTT source (broker) the target publishes to. Empty = unconfigured.",
|
||||
)
|
||||
source_kind: Literal["css", "color_vs"] = Field(
|
||||
default="css",
|
||||
description="Colour source kind: 'css' (per-mapping LED segments) or "
|
||||
"'color_vs' (single colour value source applied to all bulbs).",
|
||||
)
|
||||
color_strip_source_id: str = Field(
|
||||
default="", description="Color strip source ID (used when source_kind='css')"
|
||||
)
|
||||
color_value_source_id: str = Field(
|
||||
default="",
|
||||
description="Colour value source ID (used when source_kind='color_vs').",
|
||||
)
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
|
||||
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
|
||||
)
|
||||
base_topic: str = Field(
|
||||
default="zigbee2mqtt",
|
||||
description="Z2M MQTT base topic prefix (override if your Z2M instance is non-default).",
|
||||
)
|
||||
update_rate: Optional[BindableFloatInput] = Field(
|
||||
None, description="Publish rate Hz (bindable; 0.5-10)"
|
||||
)
|
||||
transition: Optional[BindableFloatInput] = Field(
|
||||
None, description="Z2M transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||
None, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
default=0, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
stop_action: Literal["none", "turn_off"] = Field(
|
||||
default="none",
|
||||
description="What to do with mapped bulbs when the target stops: "
|
||||
"'none' (leave as-is) or 'turn_off'.",
|
||||
)
|
||||
|
||||
|
||||
OutputTargetResponse = Annotated[
|
||||
Union[
|
||||
Annotated[LedOutputTargetResponse, Tag("led")],
|
||||
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
|
||||
Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
|
||||
],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
@@ -119,6 +198,12 @@ class _OutputTargetCreateBase(BaseModel):
|
||||
name: str = Field(description="Target name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None, max_length=64, description="Custom icon id from the curated icon library"
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None, max_length=32, description="Optional CSS color override for the icon"
|
||||
)
|
||||
|
||||
|
||||
class LedOutputTargetCreate(_OutputTargetCreateBase):
|
||||
@@ -160,7 +245,18 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
|
||||
class HALightOutputTargetCreate(_OutputTargetCreateBase):
|
||||
target_type: Literal["ha_light"] = "ha_light"
|
||||
ha_source_id: str = Field(default="", description="Home Assistant source ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
source_kind: Literal["css", "color_vs"] = Field(
|
||||
default="css",
|
||||
description="Colour source kind: 'css' (per-mapping LED segments) or "
|
||||
"'color_vs' (single colour value source applied to all entities).",
|
||||
)
|
||||
color_strip_source_id: str = Field(
|
||||
default="", description="Color strip source ID (used when source_kind='css')"
|
||||
)
|
||||
color_value_source_id: str = Field(
|
||||
default="",
|
||||
description="Colour value source ID (used when source_kind='color_vs').",
|
||||
)
|
||||
brightness: Optional[BindableFloatInput] = Field(
|
||||
default=1.0, description="Brightness (bindable)"
|
||||
)
|
||||
@@ -180,12 +276,64 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
|
||||
default=0,
|
||||
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
|
||||
)
|
||||
stop_action: Literal["none", "turn_off", "restore"] = Field(
|
||||
default="none",
|
||||
description="Finalization on stop: 'none', 'turn_off', or 'restore'.",
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
|
||||
target_type: Literal["z2m_light"] = "z2m_light"
|
||||
mqtt_source_id: str = Field(
|
||||
default="",
|
||||
description="MQTT source (broker) the target publishes to. Required to start.",
|
||||
)
|
||||
source_kind: Literal["css", "color_vs"] = Field(
|
||||
default="css",
|
||||
description="Colour source kind: 'css' or 'color_vs'.",
|
||||
)
|
||||
color_strip_source_id: str = Field(
|
||||
default="", description="Color strip source ID (used when source_kind='css')"
|
||||
)
|
||||
color_value_source_id: str = Field(
|
||||
default="",
|
||||
description="Colour value source ID (used when source_kind='color_vs').",
|
||||
)
|
||||
brightness: Optional[BindableFloatInput] = Field(
|
||||
default=1.0, description="Brightness (bindable)"
|
||||
)
|
||||
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
|
||||
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
|
||||
)
|
||||
base_topic: str = Field(
|
||||
default="zigbee2mqtt",
|
||||
max_length=128,
|
||||
description="Z2M MQTT base topic prefix.",
|
||||
)
|
||||
update_rate: Optional[BindableFloatInput] = Field(
|
||||
default=5.0, description="Publish rate in Hz (bindable; 0.5-10)"
|
||||
)
|
||||
transition: Optional[BindableFloatInput] = Field(
|
||||
default=0.3, description="Z2M transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||
default=5, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
default=0,
|
||||
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
|
||||
)
|
||||
stop_action: Literal["none", "turn_off"] = Field(
|
||||
default="none",
|
||||
description="Finalization on stop: 'none' or 'turn_off'.",
|
||||
)
|
||||
|
||||
|
||||
OutputTargetCreate = Annotated[
|
||||
Union[
|
||||
Annotated[LedOutputTargetCreate, Tag("led")],
|
||||
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
|
||||
Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
|
||||
],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
@@ -201,6 +349,16 @@ class _OutputTargetUpdateBase(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Custom icon id; pass empty string to clear and inherit from device.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon; empty string clears.",
|
||||
)
|
||||
|
||||
|
||||
class LedOutputTargetUpdate(_OutputTargetUpdateBase):
|
||||
@@ -229,7 +387,15 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase):
|
||||
class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
|
||||
target_type: Literal["ha_light"] = "ha_light"
|
||||
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
|
||||
source_kind: Optional[Literal["css", "color_vs"]] = Field(
|
||||
None,
|
||||
description="Colour source kind: 'css' or 'color_vs'.",
|
||||
)
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
color_value_source_id: Optional[str] = Field(
|
||||
None,
|
||||
description="Colour value source ID (used when source_kind='color_vs').",
|
||||
)
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
None, description="LED-to-light mappings"
|
||||
@@ -246,12 +412,53 @@ class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
None, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
stop_action: Optional[Literal["none", "turn_off", "restore"]] = Field(
|
||||
None, description="Finalization on stop: 'none', 'turn_off', or 'restore'."
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightOutputTargetUpdate(_OutputTargetUpdateBase):
|
||||
target_type: Literal["z2m_light"] = "z2m_light"
|
||||
mqtt_source_id: Optional[str] = Field(
|
||||
None,
|
||||
description="MQTT source (broker) id. Empty string clears the binding.",
|
||||
)
|
||||
source_kind: Optional[Literal["css", "color_vs"]] = Field(
|
||||
None, description="Colour source kind: 'css' or 'color_vs'."
|
||||
)
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
color_value_source_id: Optional[str] = Field(
|
||||
None, description="Colour value source ID (used when source_kind='color_vs')."
|
||||
)
|
||||
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
|
||||
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
|
||||
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
|
||||
)
|
||||
base_topic: Optional[str] = Field(
|
||||
None, max_length=128, description="Z2M MQTT base topic prefix."
|
||||
)
|
||||
update_rate: Optional[BindableFloatInput] = Field(
|
||||
None, description="Publish rate Hz (bindable; 0.5-10)"
|
||||
)
|
||||
transition: Optional[BindableFloatInput] = Field(
|
||||
None, description="Z2M transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||
None, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
None, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
stop_action: Optional[Literal["none", "turn_off"]] = Field(
|
||||
None, description="Finalization on stop: 'none' or 'turn_off'."
|
||||
)
|
||||
|
||||
|
||||
OutputTargetUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[LedOutputTargetUpdate, Tag("led")],
|
||||
Annotated[HALightOutputTargetUpdate, Tag("ha_light")],
|
||||
Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")],
|
||||
],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
@@ -280,6 +487,9 @@ class TargetProcessingState(BaseModel):
|
||||
None, description="Potential FPS (processing speed without throttle)"
|
||||
)
|
||||
fps_target: Optional[int] = Field(None, description="Target FPS")
|
||||
fps_capture: Optional[int] = Field(
|
||||
None, description="Configured capture-side FPS for the underlying color strip stream"
|
||||
)
|
||||
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
||||
frames_keepalive: Optional[int] = Field(
|
||||
None, description="Keepalive frames sent during standby"
|
||||
|
||||
@@ -17,6 +17,16 @@ class PatternTemplateCreate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PatternTemplateUpdate(BaseModel):
|
||||
@@ -28,6 +38,16 @@ class PatternTemplateUpdate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PatternTemplateResponse(BaseModel):
|
||||
@@ -40,6 +60,16 @@ class PatternTemplateResponse(BaseModel):
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PatternTemplateListResponse(BaseModel):
|
||||
|
||||
@@ -19,6 +19,16 @@ class _PictureSourceResponseBase(BaseModel):
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class RawPictureSourceResponse(_PictureSourceResponseBase):
|
||||
@@ -72,6 +82,16 @@ class _PictureSourceCreateBase(BaseModel):
|
||||
name: str = Field(description="Stream name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class RawPictureSourceCreate(_PictureSourceCreateBase):
|
||||
@@ -127,6 +147,16 @@ class _PictureSourceUpdateBase(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class RawPictureSourceUpdate(_PictureSourceUpdateBase):
|
||||
|
||||
@@ -17,6 +17,16 @@ class PostprocessingTemplateCreate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PostprocessingTemplateUpdate(BaseModel):
|
||||
@@ -28,6 +38,16 @@ class PostprocessingTemplateUpdate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PostprocessingTemplateResponse(BaseModel):
|
||||
@@ -40,6 +60,16 @@ class PostprocessingTemplateResponse(BaseModel):
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class PostprocessingTemplateListResponse(BaseModel):
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
@@ -23,6 +23,16 @@ class ScenePresetCreate(BaseModel):
|
||||
None, description="Target IDs to capture (all if omitted)"
|
||||
)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class ScenePresetUpdate(BaseModel):
|
||||
@@ -36,6 +46,16 @@ class ScenePresetUpdate(BaseModel):
|
||||
description="Update target list: keep state for existing, capture fresh for new, drop removed",
|
||||
)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class ScenePresetResponse(BaseModel):
|
||||
@@ -47,6 +67,16 @@ class ScenePresetResponse(BaseModel):
|
||||
targets: List[TargetSnapshotSchema]
|
||||
order: int
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@ class SyncClockCreate(BaseModel):
|
||||
speed: float = Field(default=1.0, description="Speed multiplier (0.1–10.0)", ge=0.1, le=10.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class SyncClockUpdate(BaseModel):
|
||||
@@ -22,6 +32,16 @@ class SyncClockUpdate(BaseModel):
|
||||
speed: Optional[float] = Field(None, description="Speed multiplier (0.1–10.0)", ge=0.1, le=10.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class SyncClockResponse(BaseModel):
|
||||
@@ -32,6 +52,16 @@ class SyncClockResponse(BaseModel):
|
||||
speed: float = Field(description="Speed multiplier")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon.",
|
||||
)
|
||||
is_running: bool = Field(True, description="Whether clock is currently running")
|
||||
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,16 @@ class TemplateCreate(BaseModel):
|
||||
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
@@ -24,6 +34,16 @@ class TemplateUpdate(BaseModel):
|
||||
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
@@ -37,6 +57,12 @@ class TemplateResponse(BaseModel):
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
icon: Optional[str] = Field(
|
||||
None, max_length=64, description="Icon id from the curated icon library."
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None, max_length=32, description="Optional CSS color override for the icon."
|
||||
)
|
||||
|
||||
|
||||
class TemplateListResponse(BaseModel):
|
||||
@@ -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):
|
||||
|
||||
@@ -17,6 +17,16 @@ class _ValueSourceResponseBase(BaseModel):
|
||||
name: str = Field(description="Source name")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon.",
|
||||
)
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
@@ -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: Optional[str] = Field(
|
||||
None, description="Optional sync clock ID for shared timing (overrides speed)"
|
||||
)
|
||||
|
||||
|
||||
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
|
||||
@@ -137,6 +151,17 @@ class SystemMetricsValueSourceResponse(_ValueSourceResponseBase):
|
||||
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
||||
|
||||
|
||||
class HTTPValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["http"] = "http"
|
||||
return_type: Literal["float"] = "float"
|
||||
http_endpoint_id: str = Field(description="HTTP endpoint ID")
|
||||
json_path: str = Field(description="Dot-path into the response body")
|
||||
interval_s: int = Field(description="Polling cadence (seconds)")
|
||||
min_value: float = Field(description="Raw value mapped to output 0.0")
|
||||
max_value: float = Field(description="Raw value mapped to output 1.0")
|
||||
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
||||
|
||||
|
||||
ValueSourceResponse = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceResponse, Tag("static")],
|
||||
@@ -152,6 +177,7 @@ ValueSourceResponse = Annotated[
|
||||
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
|
||||
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")],
|
||||
Annotated[HTTPValueSourceResponse, Tag("http")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
@@ -167,6 +193,16 @@ class _ValueSourceCreateBase(BaseModel):
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class StaticValueSourceCreate(_ValueSourceCreateBase):
|
||||
@@ -215,6 +251,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 +273,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: Optional[str] = Field(
|
||||
None, description="Optional sync clock ID (overrides speed when set)"
|
||||
)
|
||||
|
||||
|
||||
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
|
||||
@@ -278,6 +322,16 @@ class SystemMetricsValueSourceCreate(_ValueSourceCreateBase):
|
||||
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class HTTPValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["http"] = "http"
|
||||
http_endpoint_id: str = Field(description="HTTP endpoint ID")
|
||||
json_path: str = Field("", description="Dot-path into the response (empty = raw body text)")
|
||||
interval_s: int = Field(60, description="Polling cadence (seconds)", ge=1)
|
||||
min_value: float = Field(0.0, description="Raw value mapped to output 0.0")
|
||||
max_value: float = Field(100.0, description="Raw value mapped to output 1.0")
|
||||
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
ValueSourceCreate = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceCreate, Tag("static")],
|
||||
@@ -293,6 +347,7 @@ ValueSourceCreate = Annotated[
|
||||
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
|
||||
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")],
|
||||
Annotated[HTTPValueSourceCreate, Tag("http")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
@@ -308,6 +363,16 @@ class _ValueSourceUpdateBase(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class StaticValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
@@ -356,6 +421,9 @@ class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
|
||||
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
|
||||
longitude: Optional[float] = Field(
|
||||
None, description="Geographic longitude", ge=-180.0, le=180.0
|
||||
)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
|
||||
|
||||
@@ -369,7 +437,12 @@ 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")
|
||||
easing: Optional[str] = Field(
|
||||
None, description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
|
||||
)
|
||||
clock_id: Optional[str] = Field(
|
||||
None, description="Optional sync clock ID (empty string clears, null leaves unchanged)"
|
||||
)
|
||||
|
||||
|
||||
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
@@ -413,6 +486,16 @@ class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
source_type: Literal["http"] = "http"
|
||||
http_endpoint_id: Optional[str] = Field(None, description="HTTP endpoint ID")
|
||||
json_path: Optional[str] = Field(None, description="Dot-path into the response")
|
||||
interval_s: Optional[int] = Field(None, description="Polling cadence (seconds)", ge=1)
|
||||
min_value: Optional[float] = Field(None, description="Raw value mapped to 0.0")
|
||||
max_value: Optional[float] = Field(None, description="Raw value mapped to 1.0")
|
||||
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
ValueSourceUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceUpdate, Tag("static")],
|
||||
@@ -428,6 +511,7 @@ ValueSourceUpdate = Annotated[
|
||||
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
|
||||
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")],
|
||||
Annotated[HTTPValueSourceUpdate, Tag("http")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
@@ -25,6 +25,16 @@ class WeatherSourceCreate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class WeatherSourceUpdate(BaseModel):
|
||||
@@ -44,6 +54,16 @@ class WeatherSourceUpdate(BaseModel):
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class WeatherSourceResponse(BaseModel):
|
||||
@@ -60,6 +80,16 @@ class WeatherSourceResponse(BaseModel):
|
||||
update_interval: int = Field(description="API poll interval in seconds")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon.",
|
||||
)
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -83,52 +83,10 @@ class StorageConfig(BaseSettings):
|
||||
database_file: str = f"{_DEFAULT_DATA_DIR_STR}/ledgrab.db"
|
||||
|
||||
|
||||
class MQTTConfig(BaseSettings):
|
||||
"""MQTT broker configuration.
|
||||
|
||||
The ``password`` field accepts either plaintext or an ``ENC:v1:`` envelope
|
||||
(see :mod:`ledgrab.utils.secret_box`). Use :func:`resolve_mqtt_password`
|
||||
to obtain the plaintext value at runtime.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
broker_host: str = "localhost"
|
||||
broker_port: int = 1883
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
client_id: str = "ledgrab"
|
||||
base_topic: str = "ledgrab"
|
||||
|
||||
|
||||
def resolve_mqtt_password(cfg: "Config | None" = None) -> str:
|
||||
"""Return the plaintext MQTT password.
|
||||
|
||||
Accepts either an ``ENC:v1:`` envelope or legacy plaintext. If
|
||||
plaintext is detected, a warning is logged once per process start
|
||||
so the user knows to migrate.
|
||||
"""
|
||||
from ledgrab.utils import get_logger, secret_box
|
||||
|
||||
log = get_logger(__name__)
|
||||
cfg = cfg or get_config()
|
||||
pw = cfg.mqtt.password or ""
|
||||
if not pw:
|
||||
return ""
|
||||
if secret_box.is_encrypted(pw):
|
||||
try:
|
||||
return secret_box.decrypt(pw)
|
||||
except Exception as exc:
|
||||
log.error("Failed to decrypt MQTT password: %s", exc)
|
||||
return ""
|
||||
# Plaintext — warn (once)
|
||||
if not getattr(resolve_mqtt_password, "_warned", False):
|
||||
log.warning(
|
||||
"MQTT password in config.yaml is stored in plaintext. "
|
||||
"Replace with an encrypted envelope (ENC:v1:...) — see "
|
||||
"ledgrab.utils.secret_box.encrypt()."
|
||||
)
|
||||
resolve_mqtt_password._warned = True # type: ignore[attr-defined]
|
||||
return pw
|
||||
# The legacy single-broker ``MQTTConfig`` block has been removed. Brokers
|
||||
# are now first-class :class:`MQTTSource` entries managed through the UI;
|
||||
# see :mod:`ledgrab.core.mqtt.legacy_migration` for the one-shot upgrade
|
||||
# path that seeds an MQTTSource from any pre-existing ``mqtt:`` YAML block.
|
||||
|
||||
|
||||
class LoggingConfig(BaseSettings):
|
||||
@@ -158,6 +116,10 @@ class Config(BaseSettings):
|
||||
env_prefix="LEDGRAB_",
|
||||
env_nested_delimiter="__",
|
||||
case_sensitive=False,
|
||||
# ``extra="ignore"`` lets pre-existing YAML files (with the now-removed
|
||||
# ``mqtt:`` block, etc.) load without raising. The legacy MQTT block
|
||||
# is handled by ``core.mqtt.legacy_migration`` on first startup.
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
demo: bool = False
|
||||
@@ -166,7 +128,6 @@ class Config(BaseSettings):
|
||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
assets: AssetsConfig = Field(default_factory=AssetsConfig)
|
||||
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
updates: UpdatesConfig = Field(default_factory=UpdatesConfig)
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional, Set
|
||||
from typing import Callable, Dict, Optional, Set
|
||||
|
||||
from ledgrab.core.automations.platform_detector import PlatformDetector
|
||||
from ledgrab.storage.automation import (
|
||||
@@ -11,6 +12,7 @@ from ledgrab.storage.automation import (
|
||||
Automation,
|
||||
DisplayStateRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
MQTTRule,
|
||||
Rule,
|
||||
StartupRule,
|
||||
@@ -25,6 +27,53 @@ from ledgrab.utils import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RuleEvalContext:
|
||||
"""Per-tick environment passed to every rule handler.
|
||||
|
||||
Bundles all the cross-cutting state the various ``_evaluate_*``
|
||||
handlers need so adding a new handler does not require widening
|
||||
``_evaluate_rule``'s parameter list. ``frozen=True`` guards against
|
||||
a handler mutating its inputs.
|
||||
"""
|
||||
|
||||
running_procs: Set[str]
|
||||
topmost_proc: Optional[str]
|
||||
topmost_fullscreen: bool
|
||||
fullscreen_procs: Set[str]
|
||||
idle_seconds: Optional[float]
|
||||
display_state: Optional[str]
|
||||
|
||||
|
||||
def _apply_operator(operator: str, extracted, expected: str) -> bool:
|
||||
"""Compare *extracted* against *expected* using *operator*.
|
||||
|
||||
String operators (equals, not_equals, contains, regex) coerce the
|
||||
extracted value to str. Numeric operators (gt, lt) coerce both sides
|
||||
to float and return False on parse failure.
|
||||
"""
|
||||
if operator == "equals":
|
||||
return str(extracted) == expected
|
||||
if operator == "not_equals":
|
||||
return str(extracted) != expected
|
||||
if operator == "contains":
|
||||
return expected in str(extracted)
|
||||
if operator == "regex":
|
||||
try:
|
||||
return bool(re.search(expected, str(extracted)))
|
||||
except re.error as exc:
|
||||
logger.debug("HTTP poll rule regex error: %s", exc)
|
||||
return False
|
||||
if operator in ("gt", "lt"):
|
||||
try:
|
||||
lhs = float(extracted)
|
||||
rhs = float(expected)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return lhs > rhs if operator == "gt" else lhs < rhs
|
||||
return False
|
||||
|
||||
|
||||
class AutomationEngine:
|
||||
"""Evaluates automation rules and activates/deactivates scene presets."""
|
||||
|
||||
@@ -33,19 +82,21 @@ class AutomationEngine:
|
||||
automation_store: AutomationStore,
|
||||
processor_manager,
|
||||
poll_interval: float = 1.0,
|
||||
mqtt_service=None,
|
||||
scene_preset_store=None,
|
||||
target_store=None,
|
||||
device_store=None,
|
||||
ha_manager=None,
|
||||
mqtt_manager=None,
|
||||
value_stream_manager=None,
|
||||
value_source_store=None,
|
||||
):
|
||||
self._store = automation_store
|
||||
self._manager = processor_manager
|
||||
self._poll_interval = poll_interval
|
||||
self._detector = PlatformDetector()
|
||||
self._mqtt_service = mqtt_service
|
||||
self._mqtt_manager = mqtt_manager
|
||||
self._value_stream_manager = value_stream_manager
|
||||
self._value_source_store = value_source_store
|
||||
self._scene_preset_store = scene_preset_store
|
||||
self._target_store = target_store
|
||||
self._device_store = device_store
|
||||
@@ -67,12 +118,15 @@ class AutomationEngine:
|
||||
self._ha_acquired: Set[str] = set()
|
||||
# MQTT source IDs currently acquired by the engine
|
||||
self._mqtt_acquired: Set[str] = set()
|
||||
# Value source IDs currently acquired by the engine (for HTTPPollRule)
|
||||
self._value_sources_acquired: Set[str] = set()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is not None:
|
||||
return
|
||||
await self._sync_ha_runtimes()
|
||||
await self._sync_mqtt_runtimes()
|
||||
self._sync_value_stream_refs()
|
||||
self._task = asyncio.create_task(self._poll_loop())
|
||||
logger.info("Automation engine started")
|
||||
|
||||
@@ -96,6 +150,8 @@ class AutomationEngine:
|
||||
await self._release_all_ha_runtimes()
|
||||
# Release all MQTT runtimes
|
||||
await self._release_all_mqtt_runtimes()
|
||||
# Release all value-stream refs held for HTTPPollRule evaluation
|
||||
self._release_all_value_stream_refs()
|
||||
|
||||
logger.info("Automation engine stopped")
|
||||
|
||||
@@ -185,6 +241,53 @@ class AutomationEngine:
|
||||
logger.warning("Failed to release MQTT runtime %s: %s", source_id, e)
|
||||
self._mqtt_acquired = set()
|
||||
|
||||
def _get_needed_value_sources(self) -> Set[str]:
|
||||
"""Collect value source IDs referenced by enabled HTTPPollRule rules."""
|
||||
needed: Set[str] = set()
|
||||
if self._value_stream_manager is None:
|
||||
return needed
|
||||
for a in self._store.get_all_automations():
|
||||
if a.enabled:
|
||||
for r in a.rules:
|
||||
if isinstance(r, HTTPPollRule) and r.value_source_id:
|
||||
needed.add(r.value_source_id)
|
||||
return needed
|
||||
|
||||
def _sync_value_stream_refs(self) -> None:
|
||||
"""Acquire/release ValueStreams to keep HTTPPollRule sources polling.
|
||||
|
||||
Mirrors the HA/MQTT sync pattern, but talks to ``ValueStreamManager``
|
||||
(which is sync). Acquiring a stream both starts its background poll
|
||||
task and pins the ref count; releasing decrements.
|
||||
"""
|
||||
if self._value_stream_manager is None:
|
||||
return
|
||||
needed = self._get_needed_value_sources()
|
||||
for vs_id in self._value_sources_acquired - needed:
|
||||
try:
|
||||
self._value_stream_manager.release(vs_id)
|
||||
logger.debug("Released value stream for automation: %s", vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to release value stream %s: %s", vs_id, e)
|
||||
for vs_id in needed - self._value_sources_acquired:
|
||||
try:
|
||||
self._value_stream_manager.acquire(vs_id)
|
||||
logger.debug("Acquired value stream for automation: %s", vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to acquire value stream %s: %s", vs_id, e)
|
||||
self._value_sources_acquired = needed
|
||||
|
||||
def _release_all_value_stream_refs(self) -> None:
|
||||
"""Release all ValueStreams held for HTTPPollRule evaluation."""
|
||||
if self._value_stream_manager is None:
|
||||
return
|
||||
for vs_id in self._value_sources_acquired:
|
||||
try:
|
||||
self._value_stream_manager.release(vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to release value stream %s: %s", vs_id, e)
|
||||
self._value_sources_acquired = set()
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
@@ -200,6 +303,7 @@ class AutomationEngine:
|
||||
async def _evaluate_all(self) -> None:
|
||||
await self._sync_ha_runtimes()
|
||||
await self._sync_mqtt_runtimes()
|
||||
self._sync_value_stream_refs()
|
||||
async with self._eval_lock:
|
||||
await self._evaluate_all_locked()
|
||||
|
||||
@@ -339,6 +443,12 @@ class AutomationEngine:
|
||||
return all(results)
|
||||
return any(results) # "or" is default
|
||||
|
||||
# Per-rule-type handlers. Built once at class-definition time (see
|
||||
# ``_RULE_HANDLERS`` below) so the dispatch dict is not rebuilt on every
|
||||
# tick the way the old inline body used to. Each handler signature is
|
||||
# ``(self, rule, ctx: _RuleEvalContext) -> bool``.
|
||||
_RULE_HANDLERS: "Dict[type, Callable[..., bool]]"
|
||||
|
||||
def _evaluate_rule(
|
||||
self,
|
||||
rule: Rule,
|
||||
@@ -349,22 +459,63 @@ class AutomationEngine:
|
||||
idle_seconds: Optional[float],
|
||||
display_state: Optional[str],
|
||||
) -> bool:
|
||||
dispatch = {
|
||||
StartupRule: lambda r: True,
|
||||
ApplicationRule: lambda r: self._evaluate_app_rule(
|
||||
r, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
|
||||
),
|
||||
TimeOfDayRule: lambda r: self._evaluate_time_of_day(r),
|
||||
SystemIdleRule: lambda r: self._evaluate_idle(r, idle_seconds),
|
||||
DisplayStateRule: lambda r: self._evaluate_display_state(r, display_state),
|
||||
MQTTRule: lambda r: self._evaluate_mqtt(r),
|
||||
WebhookRule: lambda r: self._webhook_states.get(r.token, False),
|
||||
HomeAssistantRule: lambda r: self._evaluate_home_assistant(r),
|
||||
}
|
||||
handler = dispatch.get(type(rule))
|
||||
ctx = _RuleEvalContext(
|
||||
running_procs=running_procs,
|
||||
topmost_proc=topmost_proc,
|
||||
topmost_fullscreen=topmost_fullscreen,
|
||||
fullscreen_procs=fullscreen_procs,
|
||||
idle_seconds=idle_seconds,
|
||||
display_state=display_state,
|
||||
)
|
||||
handler = self._RULE_HANDLERS.get(type(rule))
|
||||
if handler is None:
|
||||
# Coverage of ``_RULE_HANDLERS`` is asserted at module import,
|
||||
# so reaching this branch means a Rule subclass slipped past
|
||||
# the assertion (e.g. a hand-built test instance). Log loudly
|
||||
# and fall back to the previous "treat as inactive" semantics.
|
||||
logger.warning(
|
||||
"No handler registered for rule type %s — treating as inactive",
|
||||
type(rule).__name__,
|
||||
)
|
||||
return False
|
||||
return handler(rule)
|
||||
return handler(self, rule, ctx)
|
||||
|
||||
# -- Per-rule-type handlers --
|
||||
# Bound to ``self`` via ``_RULE_HANDLERS`` lookup; each signature is
|
||||
# ``(self, rule, ctx: _RuleEvalContext) -> bool``.
|
||||
|
||||
def _handle_startup(self, rule: StartupRule, ctx: _RuleEvalContext) -> bool:
|
||||
return True
|
||||
|
||||
def _handle_application(self, rule: ApplicationRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_app_rule(
|
||||
rule,
|
||||
ctx.running_procs,
|
||||
ctx.topmost_proc,
|
||||
ctx.topmost_fullscreen,
|
||||
ctx.fullscreen_procs,
|
||||
)
|
||||
|
||||
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_time_of_day(rule)
|
||||
|
||||
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_idle(rule, ctx.idle_seconds)
|
||||
|
||||
def _handle_display_state(self, rule: DisplayStateRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_display_state(rule, ctx.display_state)
|
||||
|
||||
def _handle_mqtt(self, rule: MQTTRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_mqtt(rule)
|
||||
|
||||
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._webhook_states.get(rule.token, False)
|
||||
|
||||
def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_home_assistant(rule)
|
||||
|
||||
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_http_poll(rule)
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
||||
@@ -393,20 +544,14 @@ class AutomationEngine:
|
||||
return display_state == rule.state
|
||||
|
||||
def _evaluate_mqtt(self, rule: MQTTRule) -> bool:
|
||||
value = None
|
||||
# Try entity-based manager first (new model)
|
||||
if self._mqtt_manager is not None and rule.mqtt_source_id:
|
||||
runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
|
||||
if runtime and runtime.is_connected:
|
||||
value = runtime.get_last_value(rule.topic)
|
||||
elif self._mqtt_manager is not None:
|
||||
# No source specified — try first available runtime
|
||||
runtime = self._mqtt_manager.get_first_runtime()
|
||||
if runtime:
|
||||
value = runtime.get_last_value(rule.topic)
|
||||
# Fallback to legacy global service
|
||||
if value is None and self._mqtt_service is not None and self._mqtt_service.is_connected:
|
||||
value = self._mqtt_service.get_last_value(rule.topic)
|
||||
# Multi-broker model: the rule references a specific MQTTSource.
|
||||
# Rules without one are no-ops (UI should enforce a source on save).
|
||||
if self._mqtt_manager is None or not rule.mqtt_source_id:
|
||||
return False
|
||||
runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
|
||||
if runtime is None or not runtime.is_connected:
|
||||
return False
|
||||
value = runtime.get_last_value(rule.topic)
|
||||
if value is None:
|
||||
return False
|
||||
matchers = {
|
||||
@@ -444,6 +589,25 @@ class AutomationEngine:
|
||||
logger.debug("HA rule regex error: %s", e)
|
||||
return False
|
||||
|
||||
def _evaluate_http_poll(self, rule: HTTPPollRule) -> bool:
|
||||
"""Evaluate an HTTPPollRule by reading the referenced value source.
|
||||
|
||||
The value source (HTTPValueSource → HTTPValueStream) handles the
|
||||
actual polling + JSON extraction; the rule only compares the
|
||||
already-extracted raw value to ``rule.value`` via ``operator``.
|
||||
"""
|
||||
if self._value_stream_manager is None or not rule.value_source_id:
|
||||
return False
|
||||
stream = self._value_stream_manager.peek(rule.value_source_id)
|
||||
if stream is None or not hasattr(stream, "get_raw_value"):
|
||||
return False
|
||||
raw = stream.get_raw_value()
|
||||
if rule.operator == "exists":
|
||||
return raw is not None
|
||||
if raw is None:
|
||||
return False
|
||||
return _apply_operator(rule.operator, raw, rule.value)
|
||||
|
||||
def _evaluate_app_rule(
|
||||
self,
|
||||
rule: ApplicationRule,
|
||||
@@ -644,3 +808,57 @@ class AutomationEngine:
|
||||
"""Deactivate an automation immediately (used when disabling/deleting)."""
|
||||
if automation_id in self._active_automations:
|
||||
await self._deactivate_automation(automation_id)
|
||||
|
||||
|
||||
# Bind the per-rule-type handler table once after the class is fully defined.
|
||||
# This replaces the per-call dict-rebuild that the inline ``_evaluate_rule``
|
||||
# used to do and gives us a single place to assert coverage against the
|
||||
# Rule subclass set imported from storage.
|
||||
AutomationEngine._RULE_HANDLERS = {
|
||||
StartupRule: AutomationEngine._handle_startup,
|
||||
ApplicationRule: AutomationEngine._handle_application,
|
||||
TimeOfDayRule: AutomationEngine._handle_time_of_day,
|
||||
SystemIdleRule: AutomationEngine._handle_system_idle,
|
||||
DisplayStateRule: AutomationEngine._handle_display_state,
|
||||
MQTTRule: AutomationEngine._handle_mqtt,
|
||||
WebhookRule: AutomationEngine._handle_webhook,
|
||||
HomeAssistantRule: AutomationEngine._handle_home_assistant,
|
||||
HTTPPollRule: AutomationEngine._handle_http_poll,
|
||||
}
|
||||
|
||||
|
||||
def _assert_rule_handler_coverage() -> None:
|
||||
"""Every concrete Rule subclass imported by this module must have a handler.
|
||||
|
||||
Runs at module import so a new Rule subclass added without an
|
||||
accompanying ``_handle_*`` method + ``_RULE_HANDLERS`` entry fails the
|
||||
server boot loudly instead of silently being dropped on the floor by
|
||||
``_evaluate_rule``'s "no handler → False" fallback.
|
||||
"""
|
||||
expected = {
|
||||
StartupRule,
|
||||
ApplicationRule,
|
||||
TimeOfDayRule,
|
||||
SystemIdleRule,
|
||||
DisplayStateRule,
|
||||
MQTTRule,
|
||||
WebhookRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
}
|
||||
registered = set(AutomationEngine._RULE_HANDLERS.keys())
|
||||
missing = expected - registered
|
||||
extra = registered - expected
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing handlers: {sorted(c.__name__ for c in missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
|
||||
raise RuntimeError(
|
||||
"AutomationEngine._RULE_HANDLERS is out of sync with imported Rule subclasses: "
|
||||
+ "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_rule_handler_coverage()
|
||||
|
||||
@@ -5,6 +5,10 @@ from typing import Dict, List, Literal, Set, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.capture.edge_interpolation import (
|
||||
average_edge_to_leds,
|
||||
fallback_edge_to_leds,
|
||||
)
|
||||
from ledgrab.core.capture.screen_capture import (
|
||||
BorderPixels,
|
||||
calculate_average_color,
|
||||
@@ -233,6 +237,90 @@ class CalibrationConfig:
|
||||
return None
|
||||
|
||||
|
||||
def _build_skip_buffers(mapper, calibration: CalibrationConfig, total_leds: int) -> None:
|
||||
"""Pre-compute Phase 3 skip-LED resampling indices and scratch buffers.
|
||||
|
||||
Phase 3 takes the full ``total_leds`` strip and resamples it into
|
||||
``active_count = total_leds - skip_start - skip_end`` LEDs using linear
|
||||
interpolation. We precompute floor/ceil source indices and fractional
|
||||
weights once so per-frame work becomes a couple of ``np.take`` +
|
||||
in-place arithmetic ops with no allocations.
|
||||
|
||||
Attaches all skip-related state to ``mapper`` directly to keep the
|
||||
storage layout consistent between PixelMapper and AdvancedPixelMapper.
|
||||
"""
|
||||
skip_start = calibration.skip_leds_start
|
||||
skip_end = calibration.skip_leds_end
|
||||
mapper._skip_start = skip_start
|
||||
mapper._skip_end = skip_end
|
||||
active_count = max(0, total_leds - skip_start - skip_end)
|
||||
mapper._active_count = active_count
|
||||
|
||||
if not (0 < active_count < total_leds):
|
||||
# No skip needed (full strip used) or no active LEDs.
|
||||
mapper._skip_floor_idx = None
|
||||
mapper._skip_ceil_idx = None
|
||||
mapper._skip_frac = None
|
||||
mapper._skip_left_u8 = None
|
||||
mapper._skip_right_u8 = None
|
||||
mapper._skip_blend_f32 = None
|
||||
mapper._skip_resampled = None
|
||||
return
|
||||
|
||||
# Floor/ceil source indices and fractional weights for each
|
||||
# destination LED. ``t = src_x[k] = k * (total_leds - 1) / (active_count - 1)``
|
||||
# — equivalent to ``np.linspace(0, total_leds - 1, active_count)``.
|
||||
if active_count > 1:
|
||||
t = np.arange(active_count, dtype=np.float64) * ((total_leds - 1) / (active_count - 1))
|
||||
else:
|
||||
t = np.zeros(active_count, dtype=np.float64)
|
||||
floor_idx = np.floor(t).astype(np.int64)
|
||||
np.clip(floor_idx, 0, total_leds - 1, out=floor_idx)
|
||||
ceil_idx = np.minimum(floor_idx + 1, total_leds - 1)
|
||||
frac = (t - floor_idx).astype(np.float32)[:, None] # (active_count, 1)
|
||||
|
||||
mapper._skip_floor_idx = floor_idx
|
||||
mapper._skip_ceil_idx = ceil_idx
|
||||
mapper._skip_frac = frac
|
||||
# uint8 take destinations + float32 blend scratch — all reused per frame
|
||||
mapper._skip_left_u8 = np.empty((active_count, 3), dtype=np.uint8)
|
||||
mapper._skip_right_u8 = np.empty((active_count, 3), dtype=np.uint8)
|
||||
mapper._skip_blend_f32 = np.empty((active_count, 3), dtype=np.float32)
|
||||
mapper._skip_resampled = np.empty((active_count, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def _apply_skip_resample(mapper, led_array: np.ndarray) -> None:
|
||||
"""Phase 3 in-place resample of ``led_array`` (no allocations).
|
||||
|
||||
Applies linear interpolation precomputed in ``_build_skip_buffers`` and
|
||||
writes the result back into ``led_array`` with the configured skip
|
||||
leading/trailing zeros.
|
||||
"""
|
||||
floor_idx = mapper._skip_floor_idx
|
||||
if floor_idx is None:
|
||||
if mapper._active_count <= 0:
|
||||
led_array[:] = 0
|
||||
return
|
||||
|
||||
left_u8 = mapper._skip_left_u8
|
||||
right_u8 = mapper._skip_right_u8
|
||||
blend = mapper._skip_blend_f32
|
||||
resampled = mapper._skip_resampled
|
||||
|
||||
np.take(led_array, floor_idx, axis=0, out=left_u8)
|
||||
np.take(led_array, mapper._skip_ceil_idx, axis=0, out=right_u8)
|
||||
np.copyto(blend, right_u8, casting="unsafe") # uint8 → float32
|
||||
blend -= left_u8 # right - left
|
||||
blend *= mapper._skip_frac # frac * (right - left)
|
||||
blend += left_u8 # left + frac*(right - left)
|
||||
np.clip(blend, 0, 255, out=blend)
|
||||
np.copyto(resampled, blend, casting="unsafe") # float32 → uint8
|
||||
|
||||
led_array[:] = 0
|
||||
end_idx = mapper._total_leds - mapper._skip_end
|
||||
led_array[mapper._skip_start : end_idx] = resampled
|
||||
|
||||
|
||||
class PixelMapper:
|
||||
"""Maps screen border pixels to LED colors based on calibration."""
|
||||
|
||||
@@ -280,19 +368,10 @@ class PixelMapper:
|
||||
indices = (indices + offset) % total_leds
|
||||
self._segment_indices.append(indices)
|
||||
|
||||
# Pre-compute Phase 3 skip arrays (static geometry)
|
||||
skip_start = calibration.skip_leds_start
|
||||
skip_end = calibration.skip_leds_end
|
||||
self._skip_start = skip_start
|
||||
self._skip_end = skip_end
|
||||
self._active_count = max(0, total_leds - skip_start - skip_end)
|
||||
if 0 < self._active_count < total_leds:
|
||||
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
|
||||
self._skip_x = np.arange(total_leds, dtype=np.float64)
|
||||
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
|
||||
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
|
||||
else:
|
||||
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
|
||||
# Pre-compute Phase 3 skip — linear interpolation by precomputed
|
||||
# floor/ceil indices and fractional weights. Per-frame work is
|
||||
# entirely write-in-place into pre-allocated scratch buffers.
|
||||
_build_skip_buffers(self, calibration, total_leds)
|
||||
|
||||
# Per-edge average computation cache (lazy-initialized on first frame)
|
||||
self._edge_cache: Dict[str, tuple] = {}
|
||||
@@ -329,70 +408,17 @@ class PixelMapper:
|
||||
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
|
||||
) -> np.ndarray:
|
||||
"""Per-LED color mapping for median/dominant modes. Returns (led_count, 3) uint8."""
|
||||
if edge_name in ("top", "bottom"):
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
step = edge_len / led_count
|
||||
result = np.empty((led_count, 3), dtype=np.uint8)
|
||||
|
||||
for i in range(led_count):
|
||||
start = int(i * step)
|
||||
end = max(start + 1, int((i + 1) * step))
|
||||
end = min(end, edge_len)
|
||||
|
||||
if edge_name in ("top", "bottom"):
|
||||
segment = edge_pixels[:, start:end, :]
|
||||
else:
|
||||
segment = edge_pixels[start:end, :, :]
|
||||
|
||||
color = self._calc_color(segment)
|
||||
result[i] = color
|
||||
|
||||
return result
|
||||
return fallback_edge_to_leds(edge_pixels, edge_name, led_count, self._calc_color)
|
||||
|
||||
def _map_edge_average(
|
||||
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
|
||||
) -> np.ndarray:
|
||||
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8.
|
||||
|
||||
Uses pre-allocated cumsum/mean buffers (lazy-initialized per edge) to
|
||||
avoid per-frame allocations that cause GC-induced timing spikes.
|
||||
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
|
||||
the shared kernel handles all allocations on first use.
|
||||
"""
|
||||
if edge_name in ("top", "bottom"):
|
||||
axis = 0
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
axis = 1
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
# Lazy-init / resize per-edge scratch buffers
|
||||
cache = self._edge_cache.get(edge_name)
|
||||
if cache is None or cache[0] != edge_len:
|
||||
step = edge_len / led_count
|
||||
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||
np.minimum(boundaries, edge_len, out=boundaries)
|
||||
starts = boundaries[:-1]
|
||||
ends = boundaries[1:]
|
||||
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
|
||||
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
|
||||
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
|
||||
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
|
||||
self._edge_cache[edge_name] = cache
|
||||
|
||||
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
|
||||
|
||||
# Mean into pre-allocated buffer (no intermediate float64 array)
|
||||
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||
|
||||
# Cumsum into pre-allocated buffer
|
||||
cumsum_buf[0] = 0
|
||||
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||
|
||||
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
|
||||
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
|
||||
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, edge_name)
|
||||
|
||||
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
|
||||
"""Map screen border pixels to LED colors.
|
||||
@@ -423,18 +449,9 @@ class PixelMapper:
|
||||
|
||||
led_array[self._segment_indices[i]] = colors
|
||||
|
||||
# Phase 3: Physical skip — resample full perimeter to active LEDs
|
||||
if self._skip_src is not None:
|
||||
np.copyto(self._skip_float, led_array, casting="unsafe")
|
||||
for ch in range(3):
|
||||
self._skip_resampled[:, ch] = np.round(
|
||||
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
|
||||
).astype(np.uint8)
|
||||
led_array[:] = 0
|
||||
end_idx = self._total_leds - self._skip_end
|
||||
led_array[self._skip_start : end_idx] = self._skip_resampled
|
||||
elif self._active_count <= 0:
|
||||
led_array[:] = 0
|
||||
# Phase 3: physical skip — resample full perimeter into active LEDs
|
||||
# using precomputed weights, all in-place.
|
||||
_apply_skip_resample(self, led_array)
|
||||
|
||||
return led_array
|
||||
|
||||
@@ -514,19 +531,8 @@ class AdvancedPixelMapper:
|
||||
self._line_indices.append(indices)
|
||||
led_start += line.led_count
|
||||
|
||||
# Skip arrays (same logic as PixelMapper)
|
||||
skip_start = calibration.skip_leds_start
|
||||
skip_end = calibration.skip_leds_end
|
||||
self._skip_start = skip_start
|
||||
self._skip_end = skip_end
|
||||
self._active_count = max(0, total_leds - skip_start - skip_end)
|
||||
if 0 < self._active_count < total_leds:
|
||||
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
|
||||
self._skip_x = np.arange(total_leds, dtype=np.float64)
|
||||
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
|
||||
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
|
||||
else:
|
||||
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
|
||||
# Skip arrays — share the same buffer layout as PixelMapper
|
||||
_build_skip_buffers(self, calibration, total_leds)
|
||||
|
||||
# Per-line edge cache (keyed by line index to avoid collision)
|
||||
self._edge_cache: Dict[int, tuple] = {}
|
||||
@@ -577,34 +583,12 @@ class AdvancedPixelMapper:
|
||||
led_count: int,
|
||||
cache_key: int,
|
||||
) -> np.ndarray:
|
||||
"""Vectorized average-color mapping (same algo as PixelMapper)."""
|
||||
if edge_name in ("top", "bottom"):
|
||||
axis = 0
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
axis = 1
|
||||
edge_len = edge_pixels.shape[0]
|
||||
"""Vectorized average-color mapping; delegates to the shared kernel.
|
||||
|
||||
cache = self._edge_cache.get(cache_key)
|
||||
if cache is None or cache[0] != edge_len:
|
||||
step = edge_len / led_count
|
||||
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||
np.minimum(boundaries, edge_len, out=boundaries)
|
||||
starts = boundaries[:-1]
|
||||
ends = boundaries[1:]
|
||||
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
|
||||
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
|
||||
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
|
||||
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
|
||||
self._edge_cache[cache_key] = cache
|
||||
|
||||
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
|
||||
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||
cumsum_buf[0] = 0
|
||||
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
|
||||
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
|
||||
``cache_key`` is an integer (e.g. line index) so multiple per-line
|
||||
edges can share the same ``self._edge_cache`` dict without colliding.
|
||||
"""
|
||||
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, cache_key)
|
||||
|
||||
def _map_edge_fallback(
|
||||
self,
|
||||
@@ -612,24 +596,8 @@ class AdvancedPixelMapper:
|
||||
edge_name: str,
|
||||
led_count: int,
|
||||
) -> np.ndarray:
|
||||
"""Per-LED color mapping for median/dominant modes."""
|
||||
if edge_name in ("top", "bottom"):
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
step = edge_len / led_count
|
||||
result = np.empty((led_count, 3), dtype=np.uint8)
|
||||
for i in range(led_count):
|
||||
start = int(i * step)
|
||||
end = max(start + 1, int((i + 1) * step))
|
||||
end = min(end, edge_len)
|
||||
if edge_name in ("top", "bottom"):
|
||||
segment = edge_pixels[:, start:end, :]
|
||||
else:
|
||||
segment = edge_pixels[start:end, :, :]
|
||||
result[i] = self._calc_color(segment)
|
||||
return result
|
||||
"""Per-LED color mapping for median/dominant modes; delegates to shared kernel."""
|
||||
return fallback_edge_to_leds(edge_pixels, edge_name, led_count, self._calc_color)
|
||||
|
||||
def map_lines_to_leds(self, frames: Dict[str, np.ndarray]) -> np.ndarray:
|
||||
"""Map multi-source frames to LED colors using calibration lines.
|
||||
@@ -672,18 +640,8 @@ class AdvancedPixelMapper:
|
||||
|
||||
led_array[self._line_indices[i]] = colors
|
||||
|
||||
# Phase 3: Physical skip (same as PixelMapper)
|
||||
if self._skip_src is not None:
|
||||
np.copyto(self._skip_float, led_array, casting="unsafe")
|
||||
for ch in range(3):
|
||||
self._skip_resampled[:, ch] = np.round(
|
||||
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
|
||||
).astype(np.uint8)
|
||||
led_array[:] = 0
|
||||
end_idx = self._total_leds - self._skip_end
|
||||
led_array[self._skip_start : end_idx] = self._skip_resampled
|
||||
elif self._active_count <= 0:
|
||||
led_array[:] = 0
|
||||
# Phase 3: physical skip — same precomputed-weight resample as PixelMapper
|
||||
_apply_skip_resample(self, led_array)
|
||||
|
||||
return led_array
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Shared edge-to-LED interpolation kernels for PixelMapper variants.
|
||||
|
||||
``PixelMapper`` and ``AdvancedPixelMapper`` in ``calibration.py`` historically
|
||||
carried two byte-for-byte copies of:
|
||||
|
||||
* the fast vectorised "average across each LED segment" path
|
||||
(``_map_edge_average``) — ~80 lines of buffer-allocation + cumsum tricks; and
|
||||
* the per-LED-loop "median / dominant colour" path (``_map_edge_fallback``).
|
||||
|
||||
Lifting both kernels into pure functions removes the duplication and
|
||||
keeps the algorithms in one place. Each mapper owns its own scratch-buffer
|
||||
cache (keyed differently in the two cases — see callers); the functions
|
||||
accept that cache as an in/out dict so allocations still happen once per
|
||||
(edge_len, led_count) pair.
|
||||
|
||||
These functions intentionally do NOT touch the mappers' state beyond what
|
||||
the callers pass in, so they are trivially testable in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Hashable, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Cache value layout — kept as a tuple for the small per-frame cost of
|
||||
# tuple unpacking vs the readability of a dataclass. The first two entries
|
||||
# are the (edge_len, led_count) signature used to detect a re-build.
|
||||
_CacheEntry = Tuple[
|
||||
int, # edge_len
|
||||
int, # led_count
|
||||
np.ndarray, # starts (int64, shape (led_count,))
|
||||
np.ndarray, # ends (int64, shape (led_count,))
|
||||
np.ndarray, # lengths (float32, shape (led_count, 1))
|
||||
np.ndarray, # cumsum_buf (float32, shape (edge_len + 1, 3))
|
||||
np.ndarray, # edge_1d_buf (float32, shape (edge_len, 3))
|
||||
np.ndarray, # sums_buf (float32, shape (led_count, 3))
|
||||
np.ndarray, # starts_buf (float32, shape (led_count, 3))
|
||||
np.ndarray, # out_uint8 (uint8, shape (led_count, 3))
|
||||
]
|
||||
|
||||
|
||||
def _build_cache(edge_len: int, led_count: int) -> _CacheEntry:
|
||||
"""Pre-allocate all scratch buffers for one (edge_len, led_count) pair."""
|
||||
step = edge_len / led_count
|
||||
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||
# Ensure monotonically increasing boundaries even when ``step`` < 1.
|
||||
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||
np.minimum(boundaries, edge_len, out=boundaries)
|
||||
starts = boundaries[:-1]
|
||||
ends = boundaries[1:]
|
||||
lengths = (ends - starts).reshape(-1, 1).astype(np.float32)
|
||||
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float32)
|
||||
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float32)
|
||||
sums_buf = np.empty((led_count, 3), dtype=np.float32)
|
||||
starts_buf = np.empty((led_count, 3), dtype=np.float32)
|
||||
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
|
||||
return (
|
||||
edge_len,
|
||||
led_count,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
)
|
||||
|
||||
|
||||
def average_edge_to_leds(
|
||||
edge_pixels: np.ndarray,
|
||||
edge_name: str,
|
||||
led_count: int,
|
||||
cache: Dict[Hashable, _CacheEntry],
|
||||
cache_key: Hashable,
|
||||
) -> np.ndarray:
|
||||
"""Vectorised average colour per LED segment.
|
||||
|
||||
``edge_pixels`` is shape ``(H, W, 3)``. For top/bottom edges we average
|
||||
over axis=0 (collapsing rows), then segment along the width; for
|
||||
left/right edges we average over axis=1 then segment along the height.
|
||||
|
||||
Returns a view into the caller-owned cache's ``out_uint8`` buffer —
|
||||
do NOT retain the result across calls without copying.
|
||||
"""
|
||||
if edge_name in ("top", "bottom"):
|
||||
axis = 0
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
axis = 1
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
entry = cache.get(cache_key)
|
||||
if entry is None or entry[0] != edge_len or entry[1] != led_count:
|
||||
entry = _build_cache(edge_len, led_count)
|
||||
cache[cache_key] = entry
|
||||
|
||||
(
|
||||
_,
|
||||
_,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
) = entry
|
||||
|
||||
# Mean into pre-allocated buffer (no intermediate float64 array)
|
||||
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||
|
||||
# Cumulative sum so each LED segment's sum is two array lookups apart.
|
||||
cumsum_buf[0] = 0
|
||||
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||
|
||||
# segment_sum[i] = cumsum[ends[i]] - cumsum[starts[i]]
|
||||
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
|
||||
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
|
||||
np.subtract(sums_buf, starts_buf, out=sums_buf)
|
||||
np.divide(sums_buf, lengths, out=sums_buf)
|
||||
np.clip(sums_buf, 0, 255, out=sums_buf)
|
||||
np.copyto(out_uint8, sums_buf, casting="unsafe")
|
||||
return out_uint8
|
||||
|
||||
|
||||
def fallback_edge_to_leds(
|
||||
edge_pixels: np.ndarray,
|
||||
edge_name: str,
|
||||
led_count: int,
|
||||
calc_color: Callable[[np.ndarray], Any],
|
||||
) -> np.ndarray:
|
||||
"""Per-LED colour mapping for median / dominant modes.
|
||||
|
||||
Iterates LED segments and delegates colour reduction to ``calc_color``
|
||||
(which is e.g. ``np.median`` for median mode, ``_dominant_colour`` for
|
||||
dominant). Slower than ``average_edge_to_leds`` but supports any
|
||||
reducer over the segment's pixels.
|
||||
"""
|
||||
if edge_name in ("top", "bottom"):
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
step = edge_len / led_count
|
||||
result = np.empty((led_count, 3), dtype=np.uint8)
|
||||
|
||||
for i in range(led_count):
|
||||
start = int(i * step)
|
||||
end = max(start + 1, int((i + 1) * step))
|
||||
end = min(end, edge_len)
|
||||
|
||||
if edge_name in ("top", "bottom"):
|
||||
segment = edge_pixels[:, start:end, :]
|
||||
else:
|
||||
segment = edge_pixels[start:end, :, :]
|
||||
|
||||
result[i] = calc_color(segment)
|
||||
|
||||
return result
|
||||
@@ -14,6 +14,12 @@ from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rat
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Reused random Generator for sampling. The legacy ``np.random.randint``
|
||||
# uses the module-level RandomState which is slightly slower per-call and
|
||||
# pulls in extra import-time work; a single Generator is faster and avoids
|
||||
# global-state surprises.
|
||||
_rng = np.random.default_rng()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayInfo:
|
||||
@@ -192,8 +198,11 @@ def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10)
|
||||
left = img[:, :border_width, :]
|
||||
|
||||
logger.debug(
|
||||
f"Extracted borders: top={top.shape}, right={right.shape}, "
|
||||
f"bottom={bottom.shape}, left={left.shape}"
|
||||
"Extracted borders",
|
||||
top=top.shape,
|
||||
right=right.shape,
|
||||
bottom=bottom.shape,
|
||||
left=left.shape,
|
||||
)
|
||||
|
||||
return BorderPixels(
|
||||
@@ -303,6 +312,12 @@ def calculate_median_color(pixels: np.ndarray) -> tuple[int, int, int]:
|
||||
def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
|
||||
"""Calculate dominant color of a pixel region using simple clustering.
|
||||
|
||||
Quantizes to 32 levels/channel (5 bits/channel = 32K bins), packs into a
|
||||
single uint32, then uses ``np.bincount`` to find the most common bin.
|
||||
Sampling uses with-replacement (statistically equivalent for a dominant-bin
|
||||
search and avoids the full sort that ``np.random.choice(replace=False)``
|
||||
triggers internally).
|
||||
|
||||
Args:
|
||||
pixels: Pixel array (height, width, 3)
|
||||
|
||||
@@ -312,28 +327,27 @@ def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
|
||||
# Reshape to (n_pixels, 3)
|
||||
pixels_reshaped = pixels.reshape(-1, 3)
|
||||
n = len(pixels_reshaped)
|
||||
|
||||
# For performance, sample pixels if there are too many
|
||||
max_samples = 1000
|
||||
if len(pixels_reshaped) > max_samples:
|
||||
indices = np.random.choice(len(pixels_reshaped), max_samples, replace=False)
|
||||
if n > max_samples:
|
||||
# ``Generator.integers`` writes into a fresh buffer once per call;
|
||||
# the legacy ``np.random.randint`` did the same plus extra
|
||||
# bookkeeping. Random (not stride) sampling stays robust against
|
||||
# periodic patterns in screen pixels.
|
||||
indices = _rng.integers(0, n, size=max_samples)
|
||||
pixels_reshaped = pixels_reshaped[indices]
|
||||
|
||||
# Simple dominant color: quantize colors and find most common
|
||||
# Reduce color space to 32 levels per channel for binning
|
||||
quantized = (pixels_reshaped // 8) * 8
|
||||
|
||||
# Find unique colors and their counts
|
||||
unique_colors, counts = np.unique(quantized, axis=0, return_counts=True)
|
||||
|
||||
# Get the most common color
|
||||
dominant_idx = np.argmax(counts)
|
||||
dominant_color = unique_colors[dominant_idx]
|
||||
|
||||
r = int(np.clip(dominant_color[0], 0, 255))
|
||||
g = int(np.clip(dominant_color[1], 0, 255))
|
||||
b = int(np.clip(dominant_color[2], 0, 255))
|
||||
# Quantize to 32 levels/channel (drop low 3 bits) and pack into uint32:
|
||||
# bits 10-14 = R, bits 5-9 = G, bits 0-4 = B → 32K possible bins.
|
||||
q = pixels_reshaped >> 3 # uint8 in [0,31]
|
||||
packed = (q[:, 0].astype(np.uint32) << 10) | (q[:, 1].astype(np.uint32) << 5) | q[:, 2]
|
||||
counts = np.bincount(packed, minlength=1)
|
||||
dominant_bin = int(np.argmax(counts))
|
||||
|
||||
# Reconstruct 5-bit channels and shift back to 8-bit (centered in bin).
|
||||
r = ((dominant_bin >> 10) & 0x1F) << 3
|
||||
g = ((dominant_bin >> 5) & 0x1F) << 3
|
||||
b = (dominant_bin & 0x1F) << 3
|
||||
return (r, g, b)
|
||||
|
||||
@@ -117,6 +117,16 @@ class CaptureEngine(ABC):
|
||||
"""Get default configuration for this engine."""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_config_choices(cls) -> Dict[str, List[str]]:
|
||||
"""Return allowed values for enum-like config keys on this platform.
|
||||
|
||||
Keys returned here narrow the values the UI offers for the
|
||||
corresponding config field. Engines that have no platform-specific
|
||||
constraints can leave this empty (default).
|
||||
"""
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_available_displays(cls) -> List[DisplayInfo]:
|
||||
|
||||
@@ -35,12 +35,17 @@ class BetterCamCaptureStream(CaptureStream):
|
||||
except ImportError:
|
||||
raise RuntimeError("BetterCam not installed. Install with: pip install bettercam")
|
||||
|
||||
# Clear global camera cache for fresh DXGI state
|
||||
try:
|
||||
self._bettercam.__factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam factory cleanup on init: %s", e)
|
||||
pass
|
||||
# Clear global camera cache for fresh DXGI state.
|
||||
# NOTE: ``self._bettercam.__factory`` is name-mangled by Python to
|
||||
# ``self._bettercam._BetterCamCaptureStream__factory`` because the
|
||||
# access appears inside a class body, which silently AttributeErrors.
|
||||
# Use string-based getattr to bypass mangling.
|
||||
_factory = getattr(self._bettercam, "__factory", None)
|
||||
if _factory is not None:
|
||||
try:
|
||||
_factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam factory cleanup on init failed", error=str(e))
|
||||
|
||||
self._camera = self._bettercam.create(
|
||||
output_idx=self.display_index,
|
||||
@@ -71,11 +76,12 @@ class BetterCamCaptureStream(CaptureStream):
|
||||
self._camera = None
|
||||
|
||||
if self._bettercam:
|
||||
try:
|
||||
self._bettercam.__factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam factory cleanup on teardown: %s", e)
|
||||
pass
|
||||
_factory = getattr(self._bettercam, "__factory", None)
|
||||
if _factory is not None:
|
||||
try:
|
||||
_factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam factory cleanup on teardown failed", error=str(e))
|
||||
|
||||
self._initialized = False
|
||||
logger.info(f"BetterCam capture stream cleaned up (display={self.display_index})")
|
||||
@@ -109,8 +115,10 @@ class BetterCamCaptureStream(CaptureStream):
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"BetterCam captured display {self.display_index}: "
|
||||
f"{frame.shape[1]}x{frame.shape[0]}"
|
||||
"BetterCam captured frame",
|
||||
display=self.display_index,
|
||||
w=frame.shape[1],
|
||||
h=frame.shape[0],
|
||||
)
|
||||
|
||||
return ScreenCapture(
|
||||
|
||||
@@ -8,12 +8,19 @@ Prerequisites (optional dependency):
|
||||
pip install opencv-python-headless>=4.8.0
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
# OpenCV's MSMF backend on Windows often fails to open the device
|
||||
# ("cap.isOpened() == False" right after VideoCapture returns) when
|
||||
# hardware MFTs are enabled. Disabling them is the documented mitigation.
|
||||
# Set before any cv2 import so the MSMF backend picks it up on first use.
|
||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||
|
||||
|
||||
from ledgrab.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
@@ -27,6 +34,41 @@ logger = get_logger(__name__)
|
||||
|
||||
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
|
||||
|
||||
# Sentinel used to ask DShow/MSMF/V4L2 for the highest mode the device supports.
|
||||
# OpenCV will clamp the requested width/height down to the nearest supported mode.
|
||||
_PROBE_MAX_DIM = 9999
|
||||
|
||||
# Resolution presets shown in the UI. "auto" means: open at the camera's max
|
||||
# (probed via _PROBE_MAX_DIM); the other entries are explicit overrides.
|
||||
_RESOLUTION_CHOICES: List[str] = [
|
||||
"auto",
|
||||
"640x480",
|
||||
"1280x720",
|
||||
"1920x1080",
|
||||
"2560x1440",
|
||||
"3840x2160",
|
||||
]
|
||||
|
||||
|
||||
def _parse_resolution(value: Any) -> Optional[tuple[int, int]]:
|
||||
"""Parse a 'WxH' string into (width, height). Returns None for 'auto' or invalid."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
s = value.strip().lower()
|
||||
if s in ("", "auto"):
|
||||
return None
|
||||
parts = s.replace("×", "x").split("x")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
try:
|
||||
w, h = int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
return None
|
||||
if w <= 0 or h <= 0:
|
||||
return None
|
||||
return w, h
|
||||
|
||||
|
||||
# Process-wide registry of cv2 camera indices currently held open.
|
||||
# Prevents _enumerate_cameras from probing an in-use camera (which can
|
||||
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
|
||||
@@ -48,6 +90,85 @@ def _get_default_backend():
|
||||
return "auto"
|
||||
|
||||
|
||||
# Maps our backend ids to the label cv2.getBuildInformation() prints in the
|
||||
# Video I/O section. Entries missing or marked "NO" mean the installed
|
||||
# opencv wheel was compiled without that backend — even if cv2's registry
|
||||
# still lists it, attempts to open will fail with isOpened()==False.
|
||||
_BUILDINFO_LABELS: Dict[str, str] = {
|
||||
"dshow": "DirectShow",
|
||||
"msmf": "Media Foundation",
|
||||
"v4l2": "v4l/v4l2",
|
||||
"avfoundation": "AVFoundation",
|
||||
}
|
||||
|
||||
_compiled_backends_cache: Optional[Set[str]] = None
|
||||
|
||||
|
||||
def _get_compiled_backends() -> Set[str]:
|
||||
"""Return the set of backend ids the installed cv2 was compiled with.
|
||||
|
||||
Parses ``cv2.getBuildInformation()`` because cv2's videoio registry can
|
||||
advertise backends that aren't actually functional (e.g. wheels that
|
||||
omit Media Foundation still list MSMF in the registry).
|
||||
"""
|
||||
global _compiled_backends_cache
|
||||
if _compiled_backends_cache is not None:
|
||||
return _compiled_backends_cache
|
||||
|
||||
try:
|
||||
import cv2
|
||||
except ImportError:
|
||||
_compiled_backends_cache = set()
|
||||
return _compiled_backends_cache
|
||||
|
||||
info = cv2.getBuildInformation()
|
||||
# Restrict the search to the "Video I/O" section so labels like
|
||||
# "Media Foundation" don't pick up unrelated mentions elsewhere.
|
||||
start = info.find("Video I/O:")
|
||||
section = info[start:] if start != -1 else info
|
||||
end_markers = ("Parallel framework", "Trace:", "Other third-party libraries")
|
||||
for marker in end_markers:
|
||||
idx = section.find(marker)
|
||||
if idx != -1:
|
||||
section = section[:idx]
|
||||
break
|
||||
|
||||
found: Set[str] = set()
|
||||
for backend, label in _BUILDINFO_LABELS.items():
|
||||
# Match "<label>: <whitespace>YES" anywhere in the section.
|
||||
needle = label + ":"
|
||||
pos = section.find(needle)
|
||||
if pos == -1:
|
||||
continue
|
||||
line_end = section.find("\n", pos)
|
||||
line = section[pos : line_end if line_end != -1 else len(section)]
|
||||
if "YES" in line.upper():
|
||||
found.add(backend)
|
||||
|
||||
_compiled_backends_cache = found
|
||||
return found
|
||||
|
||||
|
||||
def _get_supported_backends() -> List[str]:
|
||||
"""Return the list of cv2 backends that make sense on this platform.
|
||||
|
||||
Only advertises backends that are both (a) appropriate for the host OS
|
||||
and (b) actually compiled into the installed opencv wheel. ``auto`` is
|
||||
always offered as a safe default.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
candidates = ["dshow", "msmf"]
|
||||
elif sys.platform.startswith("linux"):
|
||||
candidates = ["v4l2"]
|
||||
elif sys.platform == "darwin":
|
||||
candidates = ["avfoundation"]
|
||||
else:
|
||||
candidates = []
|
||||
|
||||
compiled = _get_compiled_backends()
|
||||
return ["auto", *(b for b in candidates if b in compiled)]
|
||||
|
||||
|
||||
def _cv2_backend_id(backend_name: str) -> Optional[int]:
|
||||
"""Convert a backend name string to cv2 API preference constant."""
|
||||
return _CV2_BACKENDS.get(backend_name)
|
||||
@@ -256,8 +377,20 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
|
||||
cap.release()
|
||||
continue
|
||||
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
# Probe the camera's max supported mode by asking for an absurdly large
|
||||
# frame size — DShow/MSMF/V4L2 clamp down to the highest available mode.
|
||||
# If the probe is rejected (rare driver issue), fall back to the default
|
||||
# mode that the camera reports immediately after open.
|
||||
default_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
default_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
try:
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, _PROBE_MAX_DIM)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, _PROBE_MAX_DIM)
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or default_width
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or default_height
|
||||
except Exception as e:
|
||||
logger.debug(f"Camera {i} max-resolution probe failed: {e}")
|
||||
width, height = default_width, default_height
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
|
||||
name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}")
|
||||
@@ -328,16 +461,28 @@ class CameraCaptureStream(CaptureStream):
|
||||
_active_cv2_indices.add(cv2_index)
|
||||
|
||||
try:
|
||||
# Open the camera
|
||||
# Open the camera. MSMF's first open after a DShow session (or its
|
||||
# very first cold open in the process) is timing-sensitive on
|
||||
# Windows, so retry briefly before giving up.
|
||||
backend_id = _cv2_backend_id(backend_name)
|
||||
if backend_id is not None:
|
||||
self._cap = cv2.VideoCapture(cv2_index, backend_id)
|
||||
else:
|
||||
self._cap = cv2.VideoCapture(cv2_index)
|
||||
attempts = 3 if backend_name == "msmf" else 1
|
||||
self._cap = None
|
||||
for attempt in range(attempts):
|
||||
if backend_id is not None:
|
||||
cap = cv2.VideoCapture(cv2_index, backend_id)
|
||||
else:
|
||||
cap = cv2.VideoCapture(cv2_index)
|
||||
if cap.isOpened():
|
||||
self._cap = cap
|
||||
break
|
||||
cap.release()
|
||||
if attempt + 1 < attempts:
|
||||
time.sleep(0.5)
|
||||
|
||||
if not self._cap.isOpened():
|
||||
if self._cap is None or not self._cap.isOpened():
|
||||
raise RuntimeError(
|
||||
f"Failed to open camera {self.display_index} " f"(cv2 index {cv2_index})"
|
||||
f"Failed to open camera {self.display_index} "
|
||||
f"(cv2 index {cv2_index}, backend={backend_name})"
|
||||
)
|
||||
except Exception:
|
||||
with _camera_lock:
|
||||
@@ -346,12 +491,28 @@ class CameraCaptureStream(CaptureStream):
|
||||
|
||||
self._cv2_index = cv2_index
|
||||
|
||||
# Apply optional resolution override
|
||||
res_w = self.config.get("resolution_width", 0)
|
||||
res_h = self.config.get("resolution_height", 0)
|
||||
if res_w > 0 and res_h > 0:
|
||||
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, res_w)
|
||||
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, res_h)
|
||||
# Resolve effective resolution.
|
||||
# Priority: legacy `resolution_width`/`resolution_height` (if both > 0)
|
||||
# → new `resolution` enum string (e.g. "1920x1080" or "auto")
|
||||
# → "auto" (open at the camera's max).
|
||||
# On Windows DShow/MSMF the default opening mode is typically 640x480
|
||||
# regardless of the camera's hardware ceiling, so when no explicit
|
||||
# override is given we ask for the highest mode the device supports
|
||||
# by setting an absurdly large frame size — drivers clamp down to the
|
||||
# nearest supported mode.
|
||||
legacy_w = self.config.get("resolution_width", 0) or 0
|
||||
legacy_h = self.config.get("resolution_height", 0) or 0
|
||||
if legacy_w > 0 and legacy_h > 0:
|
||||
target_w, target_h = legacy_w, legacy_h
|
||||
else:
|
||||
parsed = _parse_resolution(self.config.get("resolution", "auto"))
|
||||
if parsed is not None:
|
||||
target_w, target_h = parsed
|
||||
else:
|
||||
target_w, target_h = _PROBE_MAX_DIM, _PROBE_MAX_DIM
|
||||
|
||||
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, target_w)
|
||||
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, target_h)
|
||||
|
||||
# Test read
|
||||
ret, frame = self._cap.read()
|
||||
@@ -434,10 +595,20 @@ class CameraEngine(CaptureEngine):
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> Dict[str, Any]:
|
||||
# `resolution` is the user-facing control. Legacy numeric overrides
|
||||
# `resolution_width`/`resolution_height` are still honored if present
|
||||
# in stored configs (see CameraCaptureStream.initialize), but are no
|
||||
# longer surfaced in the default config — the dropdown replaces them.
|
||||
return {
|
||||
"camera_backend": _get_default_backend(),
|
||||
"resolution_width": 0,
|
||||
"resolution_height": 0,
|
||||
"resolution": "auto",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_config_choices(cls) -> Dict[str, List[str]]:
|
||||
return {
|
||||
"camera_backend": _get_supported_backends(),
|
||||
"resolution": list(_RESOLUTION_CHOICES),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -35,12 +35,17 @@ class DXcamCaptureStream(CaptureStream):
|
||||
except ImportError:
|
||||
raise RuntimeError("DXcam not installed. Install with: pip install dxcam")
|
||||
|
||||
# Clear global camera cache for fresh DXGI state
|
||||
try:
|
||||
self._dxcam.__factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("DXcam factory cleanup on init: %s", e)
|
||||
pass
|
||||
# Clear global camera cache for fresh DXGI state.
|
||||
# NOTE: ``self._dxcam.__factory`` is name-mangled by Python to
|
||||
# ``self._dxcam._DXcamCaptureStream__factory`` because the access
|
||||
# appears inside a class body, which silently AttributeErrors.
|
||||
# Use string-based getattr to bypass mangling.
|
||||
_factory = getattr(self._dxcam, "__factory", None)
|
||||
if _factory is not None:
|
||||
try:
|
||||
_factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("DXcam factory cleanup on init failed", error=str(e))
|
||||
|
||||
self._camera = self._dxcam.create(
|
||||
output_idx=self.display_index,
|
||||
@@ -69,11 +74,12 @@ class DXcamCaptureStream(CaptureStream):
|
||||
self._camera = None
|
||||
|
||||
if self._dxcam:
|
||||
try:
|
||||
self._dxcam.__factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("DXcam factory cleanup on teardown: %s", e)
|
||||
pass
|
||||
_factory = getattr(self._dxcam, "__factory", None)
|
||||
if _factory is not None:
|
||||
try:
|
||||
_factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("DXcam factory cleanup on teardown failed", error=str(e))
|
||||
|
||||
self._initialized = False
|
||||
logger.info(f"DXcam capture stream cleaned up (display={self.display_index})")
|
||||
@@ -107,8 +113,10 @@ class DXcamCaptureStream(CaptureStream):
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"DXcam captured display {self.display_index}: "
|
||||
f"{frame.shape[1]}x{frame.shape[0]}"
|
||||
"DXcam captured frame",
|
||||
display=self.display_index,
|
||||
w=frame.shape[1],
|
||||
h=frame.shape[0],
|
||||
)
|
||||
|
||||
return ScreenCapture(
|
||||
|
||||
@@ -5,6 +5,13 @@ from typing import Any, Dict, List, Optional
|
||||
import mss
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import cv2
|
||||
|
||||
_HAS_CV2 = True
|
||||
except ImportError:
|
||||
_HAS_CV2 = False
|
||||
|
||||
from ledgrab.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
CaptureStream,
|
||||
@@ -15,6 +22,13 @@ from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rat
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Rotating RGB output pool: keeps prior frame references stable for any
|
||||
# consumer still reading them while a new frame is written.
|
||||
_RGB_POOL_SIZE = 3
|
||||
# Number of bytes from .raw to hash for change detection (cheap pre-check
|
||||
# that avoids the full BGRA→RGB conversion when the screen is idle).
|
||||
_CHANGE_DETECT_BYTES = 256
|
||||
|
||||
|
||||
class MSSCaptureStream(CaptureStream):
|
||||
"""MSS capture stream for a specific display."""
|
||||
@@ -22,6 +36,12 @@ class MSSCaptureStream(CaptureStream):
|
||||
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||
super().__init__(display_index, config)
|
||||
self._sct = None
|
||||
# Pre-allocated RGB destination pool — avoids per-frame allocation.
|
||||
self._rgb_pool: list = [None] * _RGB_POOL_SIZE
|
||||
self._rgb_idx: int = 0
|
||||
self._rgb_shape: tuple = (0, 0)
|
||||
# Cheap hash of the previous .raw bytes, for change detection.
|
||||
self._prev_hash: Optional[int] = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
try:
|
||||
@@ -36,6 +56,7 @@ class MSSCaptureStream(CaptureStream):
|
||||
self._sct.close()
|
||||
self._sct = None
|
||||
self._initialized = False
|
||||
self._prev_hash = None
|
||||
logger.info(f"MSS capture stream cleaned up (display={self.display_index})")
|
||||
|
||||
def capture_frame(self) -> Optional[ScreenCapture]:
|
||||
@@ -55,18 +76,51 @@ class MSSCaptureStream(CaptureStream):
|
||||
monitor = self._sct.monitors[monitor_index]
|
||||
screenshot = self._sct.grab(monitor)
|
||||
|
||||
# Direct bytes→numpy (skips PIL intermediate object)
|
||||
img_array = np.frombuffer(
|
||||
screenshot.rgb,
|
||||
dtype=np.uint8,
|
||||
).reshape(screenshot.height, screenshot.width, 3)
|
||||
# Cheap change detection: hash a small slice of the raw BGRA
|
||||
# buffer. ~256 bytes is enough to differentiate any cursor/pixel
|
||||
# change. Skips the BGRA→RGB conversion when nothing changed
|
||||
# (common on idle desktops). DXcam/BetterCam return None in this
|
||||
# case natively; mss does not, so we add it here.
|
||||
raw = screenshot.raw
|
||||
sample = bytes(raw[:_CHANGE_DETECT_BYTES])
|
||||
cur_hash = hash(sample)
|
||||
if cur_hash == self._prev_hash:
|
||||
return None
|
||||
self._prev_hash = cur_hash
|
||||
|
||||
height = screenshot.height
|
||||
width = screenshot.width
|
||||
|
||||
# Reshape .raw (BGRA) — zero-copy view over the screenshot's buffer.
|
||||
# ``screenshot.rgb`` (used previously) is a pure-Python BGRA→RGB
|
||||
# rebuild costing ~6 MB/frame at 1080p in the slowest possible
|
||||
# way. cv2.cvtColor is SIMD and writes directly into our pool.
|
||||
bgra = np.frombuffer(raw, dtype=np.uint8).reshape(height, width, 4)
|
||||
|
||||
if self._rgb_shape != (height, width):
|
||||
for i in range(_RGB_POOL_SIZE):
|
||||
self._rgb_pool[i] = np.empty((height, width, 3), dtype=np.uint8)
|
||||
self._rgb_shape = (height, width)
|
||||
|
||||
dst = self._rgb_pool[self._rgb_idx]
|
||||
self._rgb_idx = (self._rgb_idx + 1) % _RGB_POOL_SIZE
|
||||
|
||||
if _HAS_CV2:
|
||||
cv2.cvtColor(bgra, cv2.COLOR_BGRA2RGB, dst=dst)
|
||||
else:
|
||||
dst[..., 0] = bgra[..., 2]
|
||||
dst[..., 1] = bgra[..., 1]
|
||||
dst[..., 2] = bgra[..., 0]
|
||||
|
||||
logger.debug(
|
||||
f"MSS captured display {self.display_index}: {monitor['width']}x{monitor['height']}"
|
||||
"MSS captured frame",
|
||||
display=self.display_index,
|
||||
w=monitor["width"],
|
||||
h=monitor["height"],
|
||||
)
|
||||
|
||||
return ScreenCapture(
|
||||
image=img_array,
|
||||
image=dst,
|
||||
width=monitor["width"],
|
||||
height=monitor["height"],
|
||||
display_index=self.display_index,
|
||||
|
||||
@@ -5,6 +5,14 @@ import sys
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import cv2
|
||||
|
||||
_HAS_CV2 = True
|
||||
except ImportError:
|
||||
_HAS_CV2 = False
|
||||
|
||||
from ledgrab.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
@@ -16,6 +24,10 @@ from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 3-slot rotating output buffer pool: ensures the consumer always has a stable
|
||||
# RGB array reference (the underlying WGC native buffer is reused per frame).
|
||||
_RGB_POOL_SIZE = 3
|
||||
|
||||
|
||||
class WGCCaptureStream(CaptureStream):
|
||||
"""WGC capture stream for a specific display."""
|
||||
@@ -29,6 +41,11 @@ class WGCCaptureStream(CaptureStream):
|
||||
self._frame_event = threading.Event()
|
||||
self._closed_event = threading.Event()
|
||||
self._frame_lock = threading.Lock()
|
||||
# Pre-allocated RGB destination buffers (rotated to keep prior frames
|
||||
# stable for any consumer still reading the previous reference).
|
||||
self._rgb_pool: list = [None] * _RGB_POOL_SIZE
|
||||
self._rgb_idx: int = 0
|
||||
self._rgb_shape: tuple = (0, 0)
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._wgc is None:
|
||||
@@ -66,10 +83,33 @@ class WGCCaptureStream(CaptureStream):
|
||||
width = frame.width
|
||||
height = frame.height
|
||||
|
||||
# WGC provides BGRA format, convert to RGB
|
||||
# Fancy indexing creates a new contiguous array — no .copy() needed
|
||||
# WGC provides BGRA. ``frame_buffer`` is a view over the
|
||||
# native side's reusable buffer — must copy out before
|
||||
# returning. Use a 3-slot rotating pool of pre-allocated
|
||||
# RGB buffers + cv2.cvtColor (SIMD) instead of numpy fancy
|
||||
# indexing. Fancy indexing would allocate ~width*height*3
|
||||
# bytes per frame (≈480 MB/s at 1080p60); the pool allocates
|
||||
# 3 buffers total and reuses them.
|
||||
frame_array = frame_buffer.reshape((height, width, 4))
|
||||
frame_rgb = frame_array[:, :, [2, 1, 0]]
|
||||
|
||||
if self._rgb_shape != (height, width):
|
||||
for i in range(_RGB_POOL_SIZE):
|
||||
self._rgb_pool[i] = np.empty((height, width, 3), dtype=np.uint8)
|
||||
self._rgb_shape = (height, width)
|
||||
|
||||
dst = self._rgb_pool[self._rgb_idx]
|
||||
self._rgb_idx = (self._rgb_idx + 1) % _RGB_POOL_SIZE
|
||||
|
||||
if _HAS_CV2:
|
||||
cv2.cvtColor(frame_array, cv2.COLOR_BGRA2RGB, dst=dst)
|
||||
frame_rgb = dst
|
||||
else:
|
||||
# Fallback: per-channel copy is still 2× faster than
|
||||
# fancy-index allocation because it writes in-place.
|
||||
dst[..., 0] = frame_array[..., 2]
|
||||
dst[..., 1] = frame_array[..., 1]
|
||||
dst[..., 2] = frame_array[..., 0]
|
||||
frame_rgb = dst
|
||||
|
||||
with self._frame_lock:
|
||||
self._latest_frame = frame_rgb
|
||||
@@ -153,8 +193,10 @@ class WGCCaptureStream(CaptureStream):
|
||||
self._cleanup_internal()
|
||||
self._initialized = False
|
||||
|
||||
# Force garbage collection to release COM objects
|
||||
gc.collect()
|
||||
# Gen-0 collect is enough to release recently-allocated COM
|
||||
# references and avoids the multi-hundred-ms full-heap pause
|
||||
# ``gc.collect()`` would cause on a heap full of frame ndarrays.
|
||||
gc.collect(0)
|
||||
logger.info(f"WGC capture stream cleaned up (display={self.display_index})")
|
||||
|
||||
def capture_frame(self) -> Optional[ScreenCapture]:
|
||||
@@ -173,7 +215,10 @@ class WGCCaptureStream(CaptureStream):
|
||||
self._frame_event.clear()
|
||||
|
||||
logger.debug(
|
||||
f"WGC captured display {self.display_index}: " f"{frame.shape[1]}x{frame.shape[0]}"
|
||||
"WGC captured frame",
|
||||
display=self.display_index,
|
||||
w=frame.shape[1],
|
||||
h=frame.shape[0],
|
||||
)
|
||||
|
||||
return ScreenCapture(
|
||||
|
||||
@@ -32,7 +32,6 @@ _PS_IDS = {
|
||||
|
||||
_CSS_IDS = {
|
||||
"gradient": "css_demo0001",
|
||||
"cycle": "css_demo0002",
|
||||
"picture": "css_demo0003",
|
||||
"audio": "css_demo0004",
|
||||
}
|
||||
@@ -267,22 +266,6 @@ def _build_color_strip_sources() -> dict:
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
_CSS_IDS["cycle"]: {
|
||||
"id": _CSS_IDS["cycle"],
|
||||
"name": "Warm Color Cycle",
|
||||
"source_type": "color_cycle",
|
||||
"description": "Smoothly cycles through warm colors",
|
||||
"clock_id": None,
|
||||
"tags": ["demo"],
|
||||
"colors": [
|
||||
[255, 60, 0],
|
||||
[255, 140, 0],
|
||||
[255, 200, 50],
|
||||
[255, 100, 20],
|
||||
],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
_CSS_IDS["picture"]: {
|
||||
"id": _CSS_IDS["picture"],
|
||||
"name": "Screen Capture — Main Display",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Adalight serial LED client — sends pixel data over serial using the Adalight protocol."""
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple
|
||||
|
||||
@@ -56,15 +57,38 @@ class AdalightClient(LEDClient):
|
||||
|
||||
# Pre-compute Adalight header if led_count is known
|
||||
self._header = _build_adalight_header(led_count) if led_count > 0 else b""
|
||||
self._header_len = len(self._header)
|
||||
|
||||
# Pre-allocate numpy buffer for brightness scaling
|
||||
self._pixel_buf = None
|
||||
# Pre-allocated wire buffer (header + RGB payload). Resized on the
|
||||
# first frame and reused thereafter so the hot path performs no
|
||||
# allocations — only a single memcpy of the pixel bytes.
|
||||
self._frame_buf: Optional[bytearray] = None
|
||||
self._frame_buf_n: int = 0
|
||||
# Scratch uint8 array used to coerce non-uint8 / non-contiguous input
|
||||
# without allocating a fresh array per frame.
|
||||
self._u8_scratch: Optional[np.ndarray] = None
|
||||
self._u8_scratch_n: int = 0
|
||||
# Dedicated single-worker executor for serial writes. Using
|
||||
# ``loop.run_in_executor`` against this avoids the per-call
|
||||
# ``contextvars.copy_context()`` and ``functools.partial`` overhead
|
||||
# that ``asyncio.to_thread`` incurs (~5–10 µs per call), and
|
||||
# guarantees FIFO ordering of writes from this client even when
|
||||
# other tasks are using the default executor.
|
||||
self._tx_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Open serial port and wait for Arduino reset."""
|
||||
try:
|
||||
self._serial = open_transport(self._port, baud_rate=self._baud_rate, timeout=1)
|
||||
await asyncio.to_thread(self._serial.open)
|
||||
# Single-worker executor — created here so the thread is bound
|
||||
# to this client's lifecycle (started on connect, shut down on
|
||||
# close). ``thread_name_prefix`` makes it identifiable in
|
||||
# diagnostics.
|
||||
self._tx_executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
thread_name_prefix=f"adalight-tx-{self._port}",
|
||||
)
|
||||
await asyncio.get_running_loop().run_in_executor(self._tx_executor, self._serial.open)
|
||||
# Wait for Arduino to finish bootloader reset (non-blocking).
|
||||
# USB-to-TTL adapters without DTR don't reset, but the delay
|
||||
# is harmless on those — keeps the path uniform.
|
||||
@@ -77,11 +101,22 @@ class AdalightClient(LEDClient):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open serial port {self._port}: {e}")
|
||||
if self._tx_executor is not None:
|
||||
self._tx_executor.shutdown(wait=False)
|
||||
self._tx_executor = None
|
||||
raise RuntimeError(f"Failed to open serial port {self._port}: {e}")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Send black frame and close the serial port."""
|
||||
if self._connected and self._serial and self._serial.is_open and self._led_count > 0:
|
||||
loop = asyncio.get_running_loop()
|
||||
executor = self._tx_executor
|
||||
if (
|
||||
self._connected
|
||||
and self._serial
|
||||
and self._serial.is_open
|
||||
and self._led_count > 0
|
||||
and executor is not None
|
||||
):
|
||||
try:
|
||||
black = np.zeros((self._led_count, 3), dtype=np.uint8)
|
||||
frame = self._build_frame(black, brightness=255)
|
||||
@@ -89,8 +124,8 @@ class AdalightClient(LEDClient):
|
||||
f"Adalight sending black frame: {self._port} "
|
||||
f"({self._led_count} LEDs, {len(frame)} bytes)"
|
||||
)
|
||||
await asyncio.to_thread(self._serial.write, frame)
|
||||
await asyncio.to_thread(self._serial.flush)
|
||||
await loop.run_in_executor(executor, self._serial.write, frame)
|
||||
await loop.run_in_executor(executor, self._serial.flush)
|
||||
logger.info(f"Adalight black frame sent and flushed: {self._port}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send black frame on close: {e}")
|
||||
@@ -108,6 +143,9 @@ class AdalightClient(LEDClient):
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing serial port: {e}")
|
||||
self._serial = None
|
||||
if self._tx_executor is not None:
|
||||
self._tx_executor.shutdown(wait=False)
|
||||
self._tx_executor = None
|
||||
logger.info(f"Adalight disconnected: {self._port}")
|
||||
|
||||
@property
|
||||
@@ -125,12 +163,15 @@ class AdalightClient(LEDClient):
|
||||
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
|
||||
brightness: Global brightness (0-255)
|
||||
"""
|
||||
if not self.is_connected:
|
||||
executor = self._tx_executor
|
||||
if not self.is_connected or executor is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
frame = self._build_frame(pixels, brightness)
|
||||
await asyncio.to_thread(self._serial.write, frame)
|
||||
# ``run_in_executor`` skips the per-call ``contextvars.copy_context``
|
||||
# / ``functools.partial`` overhead that ``asyncio.to_thread`` does.
|
||||
await asyncio.get_running_loop().run_in_executor(executor, self._serial.write, frame)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Adalight send_pixels error: {e}")
|
||||
@@ -141,17 +182,63 @@ class AdalightClient(LEDClient):
|
||||
# Serial write is blocking — use async send_pixels path instead
|
||||
return False
|
||||
|
||||
def _build_frame(self, pixels, brightness: int) -> bytes:
|
||||
"""Build a complete Adalight frame: header + brightness-scaled RGB data."""
|
||||
if isinstance(pixels, np.ndarray):
|
||||
arr = pixels.astype(np.uint16)
|
||||
else:
|
||||
arr = np.array(pixels, dtype=np.uint16)
|
||||
def _ensure_frame_buf(self, n_leds: int) -> None:
|
||||
"""Lazily allocate / resize the wire-format frame buffer.
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
np.clip(arr, 0, 255, out=arr)
|
||||
rgb_bytes = arr.astype(np.uint8).tobytes()
|
||||
return self._header + rgb_bytes
|
||||
Header bytes are written once at the front; subsequent calls only
|
||||
memcpy the pixel payload into the trailing slot.
|
||||
"""
|
||||
needed = self._header_len + n_leds * 3
|
||||
if self._frame_buf is None or len(self._frame_buf) != needed:
|
||||
buf = bytearray(needed)
|
||||
buf[: self._header_len] = self._header
|
||||
self._frame_buf = buf
|
||||
self._frame_buf_n = n_leds
|
||||
|
||||
def _ensure_u8_scratch(self, n_leds: int) -> np.ndarray:
|
||||
"""Pre-allocated (N, 3) uint8 scratch for non-conforming inputs."""
|
||||
if self._u8_scratch is None or self._u8_scratch_n != n_leds:
|
||||
self._u8_scratch = np.empty((n_leds, 3), dtype=np.uint8)
|
||||
self._u8_scratch_n = n_leds
|
||||
return self._u8_scratch
|
||||
|
||||
def _build_frame(self, pixels, brightness: int) -> bytes:
|
||||
"""Build a complete Adalight frame in the pre-allocated wire buffer.
|
||||
|
||||
The processor loop hands us a contiguous (N, 3) uint8 array with
|
||||
brightness already applied (see ``_cached_brightness``), so the hot
|
||||
path is one memcpy from the pixel buffer into the trailing slot of
|
||||
``_frame_buf``. All other input shapes (lists of tuples, wrong
|
||||
dtype, non-contiguous views) coerce into a pre-allocated uint8
|
||||
scratch before the memcpy — still allocation-free in steady state.
|
||||
"""
|
||||
if isinstance(pixels, np.ndarray):
|
||||
n_leds = pixels.shape[0]
|
||||
if pixels.dtype == np.uint8 and pixels.flags["C_CONTIGUOUS"]:
|
||||
# Hot path: input matches wire format exactly.
|
||||
arr = pixels
|
||||
else:
|
||||
# Slow path: dtype mismatch or non-contiguous view. Coerce
|
||||
# into a pre-allocated uint8 scratch. Wider integer dtypes
|
||||
# are clamped to [0, 255] to match historical behaviour.
|
||||
arr = self._ensure_u8_scratch(n_leds)
|
||||
if pixels.dtype != np.uint8:
|
||||
# Clamp wider integer dtypes to [0, 255] before the
|
||||
# uint8 narrowing copy. This is the rare slow path —
|
||||
# one extra allocation here is fine.
|
||||
np.copyto(arr, np.clip(pixels, 0, 255), casting="unsafe")
|
||||
else:
|
||||
np.copyto(arr, pixels)
|
||||
else:
|
||||
# List/tuple input — rare path, only hit by tests/legacy callers.
|
||||
arr = np.array(pixels, dtype=np.uint8)
|
||||
n_leds = arr.shape[0]
|
||||
|
||||
self._ensure_frame_buf(n_leds)
|
||||
# memcpy pixel bytes into the trailing slot of the pre-built buffer
|
||||
view = memoryview(self._frame_buf)
|
||||
view[self._header_len :] = memoryview(arr).cast("B")
|
||||
return self._frame_buf
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
|
||||
@@ -24,6 +24,7 @@ import numpy as np
|
||||
from ledgrab.core.devices.ble_protocols import BLEProtocol, get_protocol
|
||||
from ledgrab.core.devices.ble_transport import make_transport
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -63,25 +64,6 @@ def _strip_ble_scheme(url: str) -> str:
|
||||
return url.strip("/")
|
||||
|
||||
|
||||
def _average_color(pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> Tuple[int, int, int]:
|
||||
"""Reduce an N-pixel strip to one average RGB."""
|
||||
if isinstance(pixels, np.ndarray):
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
arr = pixels.reshape(-1, 3) if pixels.ndim > 1 else pixels[:3].reshape(1, 3)
|
||||
mean = arr.mean(axis=0)
|
||||
return int(mean[0]), int(mean[1]), int(mean[2])
|
||||
if not pixels:
|
||||
return (0, 0, 0)
|
||||
total_r = total_g = total_b = 0
|
||||
for r, g, b in pixels:
|
||||
total_r += r
|
||||
total_g += g
|
||||
total_b += b
|
||||
n = len(pixels)
|
||||
return total_r // n, total_g // n, total_b // n
|
||||
|
||||
|
||||
class BLEClient(LEDClient):
|
||||
"""LED client for BLE controllers speaking one of the registered protocols.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user