Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
+13
-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,5 @@ tmp/
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,3 +104,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>
|
||||
|
||||
@@ -1,5 +1,409 @@
|
||||
# LedGrab TODO
|
||||
|
||||
## 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
|
||||
|
||||
- [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,7 +524,7 @@ 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)
|
||||
|
||||
@@ -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
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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
|
||||
# Fallback version — kept in sync with pyproject.toml.
|
||||
# Fallback version — kept in sync with pyproject.toml. MUST match the
|
||||
# version declared there on every release. The Windows installer build
|
||||
# (build/build-dist.ps1) also patches this literal to the resolved build
|
||||
# version, so any drift here is corrected for bundled distributions.
|
||||
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
|
||||
# on Android, where the source is included directly via source sets).
|
||||
_FALLBACK_VERSION = "0.3.0"
|
||||
# on Android, where the source is included directly via source sets, or
|
||||
# in the Windows bundle where the installed dist-info is stripped).
|
||||
_FALLBACK_VERSION = "0.4.2"
|
||||
|
||||
try:
|
||||
__version__ = version("ledgrab")
|
||||
|
||||
@@ -12,6 +12,8 @@ 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:
|
||||
@@ -54,9 +56,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 +83,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:
|
||||
@@ -102,8 +130,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,),
|
||||
|
||||
@@ -31,6 +31,7 @@ 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)
|
||||
@@ -62,5 +63,6 @@ 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"]
|
||||
|
||||
@@ -142,6 +142,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))
|
||||
|
||||
@@ -122,6 +122,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 +193,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 +289,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
|
||||
|
||||
@@ -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,7 +4,6 @@ from ledgrab.api.schemas.color_strip_sources import (
|
||||
ApiInputCSSResponse,
|
||||
AudioCSSResponse,
|
||||
CandlelightCSSResponse,
|
||||
ColorCycleCSSResponse,
|
||||
ColorStop as ColorStopSchema,
|
||||
ColorStripSourceResponse,
|
||||
CompositeCSSResponse,
|
||||
@@ -31,7 +30,6 @@ from ledgrab.storage.color_strip_source import (
|
||||
ApiInputColorStripSource,
|
||||
AudioColorStripSource,
|
||||
CandlelightColorStripSource,
|
||||
ColorCycleColorStripSource,
|
||||
CompositeColorStripSource,
|
||||
DaylightColorStripSource,
|
||||
EffectColorStripSource,
|
||||
@@ -67,6 +65,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 "",
|
||||
)
|
||||
|
||||
|
||||
@@ -121,10 +121,6 @@ _RESPONSE_MAP: dict = {
|
||||
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(
|
||||
**kw,
|
||||
effect_type=s.effect_type,
|
||||
|
||||
@@ -31,7 +31,6 @@ router = APIRouter()
|
||||
_PREVIEW_ALLOWED_TYPES = {
|
||||
"static",
|
||||
"gradient",
|
||||
"color_cycle",
|
||||
"effect",
|
||||
"daylight",
|
||||
"candlelight",
|
||||
@@ -476,13 +475,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
|
||||
|
||||
@@ -71,6 +71,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,
|
||||
)
|
||||
@@ -439,6 +441,8 @@ 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
|
||||
|
||||
@@ -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 "",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -11,6 +11,7 @@ from ledgrab.api.dependencies import (
|
||||
get_device_store,
|
||||
get_output_target_store,
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from ledgrab.api.schemas.output_targets import (
|
||||
HALightMappingSchema,
|
||||
@@ -30,6 +31,7 @@ from ledgrab.storage.ha_light_output_target import (
|
||||
HALightOutputTarget,
|
||||
)
|
||||
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 +56,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 +70,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,13 +89,42 @@ 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 _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')"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _target_to_response(target) -> OutputTargetResponse:
|
||||
"""Convert any OutputTarget to the appropriate typed response."""
|
||||
if isinstance(target, WledOutputTarget):
|
||||
@@ -119,6 +155,7 @@ 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),
|
||||
):
|
||||
"""Create a new output target."""
|
||||
try:
|
||||
@@ -130,6 +167,15 @@ async def create_target(
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
# Validate color VS reference for HA-light targets in color_vs mode
|
||||
if (
|
||||
getattr(data, "target_type", "") == "ha_light"
|
||||
and getattr(data, "source_kind", "css") == "color_vs"
|
||||
):
|
||||
_validate_color_value_source(
|
||||
value_source_store, getattr(data, "color_value_source_id", "")
|
||||
)
|
||||
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = (
|
||||
[
|
||||
@@ -161,10 +207,13 @@ async def create_target(
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=getattr(data, "ha_source_id", ""),
|
||||
source_kind=getattr(data, "source_kind", "css"),
|
||||
color_value_source_id=getattr(data, "color_value_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),
|
||||
stop_action=getattr(data, "stop_action", "none"),
|
||||
)
|
||||
|
||||
# Register in processor manager
|
||||
@@ -243,6 +292,7 @@ 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),
|
||||
):
|
||||
"""Update a output target."""
|
||||
try:
|
||||
@@ -254,6 +304,21 @@ async def update_target(
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
# Validate color VS reference for HA-light targets switching into / staying in color_vs
|
||||
if getattr(data, "target_type", "") == "ha_light":
|
||||
new_kind = getattr(data, "source_kind", None)
|
||||
new_color_vs = getattr(data, "color_value_source_id", None)
|
||||
if new_kind == "color_vs" or (new_kind is None and new_color_vs):
|
||||
# Determine effective id: payload id if provided, else existing target's id
|
||||
effective_id = new_color_vs
|
||||
if effective_id is None:
|
||||
try:
|
||||
existing = target_store.get_target(target_id)
|
||||
effective_id = getattr(existing, "color_value_source_id", "")
|
||||
except ValueError:
|
||||
effective_id = ""
|
||||
_validate_color_value_source(value_source_store, effective_id or "")
|
||||
|
||||
# Build HA light mappings if provided
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = None
|
||||
@@ -283,11 +348,16 @@ async def update_target(
|
||||
protocol=getattr(data, "protocol", None),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
ha_source_id=getattr(data, "ha_source_id", None),
|
||||
source_kind=getattr(data, "source_kind", None),
|
||||
color_value_source_id=getattr(data, "color_value_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),
|
||||
stop_action=getattr(data, "stop_action", None),
|
||||
)
|
||||
|
||||
# Sync processor manager (run in thread — css release/acquire can block)
|
||||
@@ -301,6 +371,9 @@ async def update_target(
|
||||
transition = getattr(data, "transition", None)
|
||||
color_tolerance = getattr(data, "color_tolerance", None)
|
||||
brightness = getattr(data, "brightness", None)
|
||||
stop_action = getattr(data, "stop_action", None)
|
||||
source_kind = getattr(data, "source_kind", None)
|
||||
color_value_source_id = getattr(data, "color_value_source_id", None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
@@ -317,6 +390,9 @@ async def update_target(
|
||||
or color_tolerance is not None
|
||||
or ha_light_mappings_raw is not None
|
||||
or brightness is not None
|
||||
or stop_action is not None
|
||||
or source_kind is not None
|
||||
or color_value_source_id is not None
|
||||
),
|
||||
css_changed=color_strip_source_id is not None,
|
||||
brightness_changed=brightness is not None,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,6 +93,13 @@ def _get_cpu_name() -> str | None:
|
||||
|
||||
_cpu_name: str | None = _get_cpu_name()
|
||||
|
||||
# Captured at first import of this module. Process-wide elapsed time is
|
||||
# the closest the server has to "app start" without instrumenting main.py;
|
||||
# the system module is imported during router setup, before the server
|
||||
# accepts requests, so the drift is negligible. Used by /health to expose
|
||||
# uptime_seconds for the transport-bar ticker.
|
||||
_APP_START_MONOTONIC: float = time.monotonic()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -122,6 +130,7 @@ async def health_check(request: Request):
|
||||
setup_required=setup_required,
|
||||
repo_url=REPO_URL,
|
||||
donate_url=DONATE_URL,
|
||||
uptime_seconds=time.monotonic() - _APP_START_MONOTONIC,
|
||||
)
|
||||
|
||||
|
||||
@@ -316,6 +325,15 @@ def get_system_performance(_: AuthRequired):
|
||||
except Exception as e:
|
||||
logger.debug("NVML query failed: %s", e)
|
||||
|
||||
# Windows has no user-space CPU die temperature source without a kernel
|
||||
# driver. We rely on LibreHardwareMonitor / OpenHardwareMonitor publishing
|
||||
# WMI sensors when the user runs them. When no reading arrives, surface
|
||||
# that explicitly so the dashboard can show a "here's how to enable it"
|
||||
# hint instead of silently hiding the card.
|
||||
cpu_temp_hint_key: str | None = None
|
||||
if thermals.cpu_temp_c is None and platform.system() == "Windows":
|
||||
cpu_temp_hint_key = "dashboard.perf.temp.install_lhm"
|
||||
|
||||
return PerformanceResponse(
|
||||
cpu_name=_cpu_name,
|
||||
cpu_percent=metrics.cpu_percent(),
|
||||
@@ -328,6 +346,7 @@ def get_system_performance(_: AuthRequired):
|
||||
battery_percent=thermals.battery_percent,
|
||||
battery_temp_c=thermals.battery_temp_c,
|
||||
cpu_temp_c=thermals.cpu_temp_c,
|
||||
cpu_temp_hint_key=cpu_temp_hint_key,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -64,6 +64,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 +75,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 +89,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 +106,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 +122,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 +133,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 +158,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 +174,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 +187,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 +200,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,
|
||||
@@ -202,6 +226,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 +242,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 +259,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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -67,6 +67,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 +94,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 +128,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):
|
||||
@@ -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")
|
||||
@@ -241,7 +246,6 @@ ColorStripSourceResponse = Annotated[
|
||||
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSResponse, Tag("static")],
|
||||
Annotated[GradientCSSResponse, Tag("gradient")],
|
||||
Annotated[ColorCycleCSSResponse, Tag("color_cycle")],
|
||||
Annotated[EffectCSSResponse, Tag("effect")],
|
||||
Annotated[CompositeCSSResponse, Tag("composite")],
|
||||
Annotated[MappedCSSResponse, Tag("mapped")],
|
||||
@@ -272,6 +276,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):
|
||||
@@ -303,11 +317,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")
|
||||
@@ -431,7 +440,6 @@ ColorStripSourceCreate = Annotated[
|
||||
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSCreate, Tag("static")],
|
||||
Annotated[GradientCSSCreate, Tag("gradient")],
|
||||
Annotated[ColorCycleCSSCreate, Tag("color_cycle")],
|
||||
Annotated[EffectCSSCreate, Tag("effect")],
|
||||
Annotated[CompositeCSSCreate, Tag("composite")],
|
||||
Annotated[MappedCSSCreate, Tag("mapped")],
|
||||
@@ -462,6 +470,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):
|
||||
@@ -493,11 +511,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")
|
||||
@@ -619,7 +632,6 @@ ColorStripSourceUpdate = Annotated[
|
||||
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSUpdate, Tag("static")],
|
||||
Annotated[GradientCSSUpdate, Tag("gradient")],
|
||||
Annotated[ColorCycleCSSUpdate, Tag("color_cycle")],
|
||||
Annotated[EffectCSSUpdate, Tag("effect")],
|
||||
Annotated[CompositeCSSUpdate, Tag("composite")],
|
||||
Annotated[MappedCSSUpdate, Tag("mapped")],
|
||||
@@ -655,10 +667,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(
|
||||
|
||||
@@ -86,6 +86,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):
|
||||
@@ -140,6 +151,17 @@ 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 CalibrationLineSchema(BaseModel):
|
||||
@@ -295,6 +317,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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -55,6 +55,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 +83,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,6 +112,11 @@ 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).",
|
||||
)
|
||||
|
||||
|
||||
OutputTargetResponse = Annotated[
|
||||
@@ -119,6 +138,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 +185,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,6 +216,10 @@ 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'.",
|
||||
)
|
||||
|
||||
|
||||
OutputTargetCreate = Annotated[
|
||||
@@ -201,6 +241,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 +279,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,6 +304,9 @@ 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'."
|
||||
)
|
||||
|
||||
|
||||
OutputTargetUpdate = Annotated[
|
||||
@@ -280,6 +341,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):
|
||||
@@ -167,6 +181,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 +239,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 +261,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):
|
||||
@@ -308,6 +340,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 +398,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 +414,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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ class MQTTConfig(BaseSettings):
|
||||
base_topic: str = "ledgrab"
|
||||
|
||||
|
||||
def resolve_mqtt_password(cfg: "Config | None" = None) -> str:
|
||||
def resolve_mqtt_password(config: "Config | None" = None) -> str:
|
||||
"""Return the plaintext MQTT password.
|
||||
|
||||
Accepts either an ``ENC:v1:`` envelope or legacy plaintext. If
|
||||
@@ -110,8 +110,8 @@ def resolve_mqtt_password(cfg: "Config | None" = None) -> str:
|
||||
from ledgrab.utils import get_logger, secret_box
|
||||
|
||||
log = get_logger(__name__)
|
||||
cfg = cfg or get_config()
|
||||
pw = cfg.mqtt.password or ""
|
||||
config = config or get_config()
|
||||
pw = config.mqtt.password or ""
|
||||
if not pw:
|
||||
return ""
|
||||
if secret_box.is_encrypted(pw):
|
||||
|
||||
@@ -233,6 +233,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 +364,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] = {}
|
||||
@@ -357,8 +432,9 @@ class PixelMapper:
|
||||
) -> 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.
|
||||
Uses pre-allocated cumsum/mean buffers AND pre-allocated output
|
||||
buffers (lazy-initialized per edge). All per-frame numpy ops write
|
||||
in-place — zero allocations on the hot path.
|
||||
"""
|
||||
if edge_name in ("top", "bottom"):
|
||||
axis = 0
|
||||
@@ -369,7 +445,7 @@ class PixelMapper:
|
||||
|
||||
# Lazy-init / resize per-edge scratch buffers
|
||||
cache = self._edge_cache.get(edge_name)
|
||||
if cache is None or cache[0] != edge_len:
|
||||
if cache is None or cache[0] != edge_len or cache[1] != led_count:
|
||||
step = edge_len / led_count
|
||||
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||
@@ -379,20 +455,53 @@ class PixelMapper:
|
||||
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)
|
||||
sums_buf = np.empty((led_count, 3), dtype=np.float64)
|
||||
starts_buf = np.empty((led_count, 3), dtype=np.float64)
|
||||
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
|
||||
cache = (
|
||||
edge_len,
|
||||
led_count,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
)
|
||||
self._edge_cache[edge_name] = cache
|
||||
|
||||
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
|
||||
(
|
||||
_,
|
||||
_,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
) = cache
|
||||
|
||||
# Mean into pre-allocated buffer (no intermediate float64 array)
|
||||
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||
|
||||
# Cumsum into pre-allocated buffer
|
||||
# Cumsum into pre-allocated buffer (cumsum_buf[0] left at 0 from init)
|
||||
cumsum_buf[0] = 0
|
||||
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||
|
||||
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
|
||||
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
|
||||
# segment_sums = cumsum_buf[ends] - cumsum_buf[starts] — but each
|
||||
# fancy-index expression allocates. np.take with ``out=`` writes
|
||||
# directly into our pre-allocated scratch.
|
||||
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
|
||||
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
|
||||
np.subtract(sums_buf, starts_buf, out=sums_buf)
|
||||
np.divide(sums_buf, lengths, out=sums_buf)
|
||||
np.clip(sums_buf, 0, 255, out=sums_buf)
|
||||
np.copyto(out_uint8, sums_buf, casting="unsafe")
|
||||
return out_uint8
|
||||
|
||||
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
|
||||
"""Map screen border pixels to LED colors.
|
||||
@@ -423,18 +532,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 +614,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] = {}
|
||||
@@ -586,7 +675,7 @@ class AdvancedPixelMapper:
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
cache = self._edge_cache.get(cache_key)
|
||||
if cache is None or cache[0] != edge_len:
|
||||
if cache is None or cache[0] != edge_len or cache[1] != led_count:
|
||||
step = edge_len / led_count
|
||||
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||
@@ -596,15 +685,45 @@ class AdvancedPixelMapper:
|
||||
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)
|
||||
sums_buf = np.empty((led_count, 3), dtype=np.float64)
|
||||
starts_buf = np.empty((led_count, 3), dtype=np.float64)
|
||||
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
|
||||
cache = (
|
||||
edge_len,
|
||||
led_count,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
)
|
||||
self._edge_cache[cache_key] = cache
|
||||
|
||||
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
|
||||
(
|
||||
_,
|
||||
_,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
) = cache
|
||||
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||
cumsum_buf[0] = 0
|
||||
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
|
||||
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
|
||||
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 _map_edge_fallback(
|
||||
self,
|
||||
@@ -672,18 +791,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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -39,6 +39,9 @@ class DDPClient:
|
||||
DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame)
|
||||
DDP_TYPE_RGB = 0x01
|
||||
|
||||
# Pre-built struct.Struct for the 10-byte DDP header (avoids per-call format parsing)
|
||||
_HEADER_STRUCT = struct.Struct("!BBB B I H")
|
||||
|
||||
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
|
||||
"""Initialize DDP client.
|
||||
|
||||
@@ -57,6 +60,10 @@ class DDPClient:
|
||||
# Pre-allocated RGBW buffer (resized on demand)
|
||||
self._rgbw_buf: Optional[np.ndarray] = None
|
||||
self._rgbw_buf_n: int = 0
|
||||
# Pre-allocated send buffer (header + payload). Sized lazily on first
|
||||
# send so we never allocate fresh bytes per frame on the hot path.
|
||||
self._send_buf: Optional[bytearray] = None
|
||||
self._send_view: Optional[memoryview] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Establish UDP connection."""
|
||||
@@ -93,52 +100,52 @@ class DDPClient:
|
||||
f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})"
|
||||
)
|
||||
|
||||
def _build_ddp_packet(
|
||||
self,
|
||||
rgb_data: bytes,
|
||||
offset: int = 0,
|
||||
sequence: int = 1,
|
||||
push: bool = False,
|
||||
) -> bytes:
|
||||
"""Build a DDP packet.
|
||||
def _ensure_send_buf(self, capacity: int) -> None:
|
||||
"""Lazily allocate / grow the per-instance send buffer.
|
||||
|
||||
DDP packet format (10-byte header + data):
|
||||
- Byte 0: Flags (VER1 | PUSH on last packet)
|
||||
- Byte 1: Sequence number
|
||||
- Byte 2: Data type (0x01 = RGB)
|
||||
- Byte 3: Source/Destination ID
|
||||
- Bytes 4-7: Data offset (4 bytes, big-endian)
|
||||
- Bytes 8-9: Data length (2 bytes, big-endian)
|
||||
- Bytes 10+: Pixel data
|
||||
|
||||
Args:
|
||||
rgb_data: RGB pixel data as bytes
|
||||
offset: Byte offset (pixel_index * 3)
|
||||
sequence: Sequence number (0-255)
|
||||
push: True for the last packet of a frame
|
||||
|
||||
Returns:
|
||||
Complete DDP packet as bytes
|
||||
``capacity`` is the largest packet we may emit (header + payload).
|
||||
Once sized, the buffer is reused for every subsequent send so the
|
||||
hot path stays allocation-free.
|
||||
"""
|
||||
flags = self.DDP_FLAGS_VER1
|
||||
if push:
|
||||
flags |= self.DDP_FLAGS_PUSH
|
||||
data_type = self.DDP_TYPE_RGB
|
||||
source_id = 0x01
|
||||
data_len = len(rgb_data)
|
||||
buf = self._send_buf
|
||||
if buf is None or len(buf) < capacity:
|
||||
self._send_buf = bytearray(capacity)
|
||||
self._send_view = memoryview(self._send_buf)
|
||||
|
||||
# Build header (10 bytes)
|
||||
header = struct.pack(
|
||||
"!BBB B I H", # Network byte order (big-endian)
|
||||
flags, # Flags
|
||||
sequence, # Sequence
|
||||
data_type, # Data type
|
||||
source_id, # Source/Destination
|
||||
offset, # Data offset (4 bytes)
|
||||
data_len, # Data length (2 bytes)
|
||||
def _emit_packet(
|
||||
self,
|
||||
payload: memoryview,
|
||||
offset: int,
|
||||
sequence: int,
|
||||
push: bool,
|
||||
) -> None:
|
||||
"""Pack header + payload into the pre-allocated send buffer and emit.
|
||||
|
||||
DDP packet layout (10-byte header):
|
||||
[0] Flags (VER1 | PUSH on last)
|
||||
[1] Sequence
|
||||
[2] Data type (0x01 = RGB)
|
||||
[3] Source/Destination ID
|
||||
[4-7] Data offset (big-endian)
|
||||
[8-9] Data length (big-endian)
|
||||
[10+] Pixel data
|
||||
"""
|
||||
flags = self.DDP_FLAGS_VER1 | (self.DDP_FLAGS_PUSH if push else 0)
|
||||
data_len = len(payload)
|
||||
self._ensure_send_buf(10 + data_len)
|
||||
buf = self._send_buf
|
||||
view = self._send_view
|
||||
# Fill header into pre-allocated buffer (no allocation)
|
||||
self._HEADER_STRUCT.pack_into(
|
||||
buf, 0, flags, sequence, self.DDP_TYPE_RGB, 0x01, offset, data_len
|
||||
)
|
||||
|
||||
return header + rgb_data
|
||||
# Copy payload bytes into buffer (single memcpy)
|
||||
view[10 : 10 + data_len] = payload
|
||||
# asyncio's selector_datagram_transport.sendto fast-path calls
|
||||
# socket.sendto(data) which accepts a buffer-like; it only copies to
|
||||
# bytes when the OS send buffer is full and the datagram must be
|
||||
# queued. So passing a memoryview is safe and avoids `bytes(...)`.
|
||||
self._transport.sendto(view[: 10 + data_len])
|
||||
|
||||
def _reorder_pixels_numpy(self, pixel_array: np.ndarray) -> np.ndarray:
|
||||
"""Apply per-bus color order reordering using numpy fancy indexing.
|
||||
@@ -168,13 +175,39 @@ class DDPClient:
|
||||
|
||||
return result
|
||||
|
||||
def _send_buffer(self, payload_view: memoryview, bpp: int, max_packet_size: int) -> int:
|
||||
"""Chunk and emit a contiguous payload via DDP. Returns packet count.
|
||||
|
||||
``payload_view`` is a 1-D bytes-like view; the caller guarantees its
|
||||
length is a multiple of ``bpp``. Each emitted packet is sized to a
|
||||
whole number of pixels so RGB channels never split across packets.
|
||||
"""
|
||||
total_bytes = len(payload_view)
|
||||
max_payload = max_packet_size - 10 # 10-byte header
|
||||
bytes_per_packet = (max_payload // bpp) * bpp
|
||||
if bytes_per_packet <= 0:
|
||||
bytes_per_packet = bpp # degenerate guard
|
||||
|
||||
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
|
||||
for i in range(num_packets):
|
||||
start = i * bytes_per_packet
|
||||
end = total_bytes if (i == num_packets - 1) else (start + bytes_per_packet)
|
||||
self._sequence = (self._sequence + 1) % 256
|
||||
self._emit_packet(
|
||||
payload_view[start:end],
|
||||
offset=start,
|
||||
sequence=self._sequence,
|
||||
push=(i == num_packets - 1),
|
||||
)
|
||||
return num_packets
|
||||
|
||||
async def send_pixels(
|
||||
self, pixels: List[Tuple[int, int, int]], max_packet_size: int = 1400
|
||||
) -> bool:
|
||||
"""Send pixel data via DDP.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples
|
||||
pixels: List of (R, G, B) tuples or an (N, 3) uint8 numpy array
|
||||
max_packet_size: Maximum UDP packet size (default 1400 bytes for safety)
|
||||
|
||||
Returns:
|
||||
@@ -187,65 +220,17 @@ class DDPClient:
|
||||
raise RuntimeError("DDP client not connected")
|
||||
|
||||
try:
|
||||
# Send plain RGB — WLED handles per-bus color order conversion
|
||||
# internally when outputting to hardware.
|
||||
# Accept numpy arrays directly to avoid per-pixel Python loop
|
||||
bpp = 4 if self.rgbw else 3 # bytes per pixel
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_array = pixels
|
||||
else:
|
||||
pixel_array = np.array(pixels, dtype=np.uint8)
|
||||
if self.rgbw:
|
||||
n = pixel_array.shape[0]
|
||||
if n != self._rgbw_buf_n:
|
||||
self._rgbw_buf = np.zeros((n, 4), dtype=np.uint8)
|
||||
self._rgbw_buf_n = n
|
||||
self._rgbw_buf[:, :3] = pixel_array
|
||||
pixel_array = self._rgbw_buf
|
||||
pixel_bytes = pixel_array.tobytes()
|
||||
|
||||
total_bytes = len(pixel_bytes)
|
||||
# Align payload to full pixels (multiple of bpp) to avoid splitting
|
||||
# a pixel's channels across packets
|
||||
max_payload = max_packet_size - 10 # 10-byte header
|
||||
bytes_per_packet = (max_payload // bpp) * bpp
|
||||
|
||||
# Split into multiple packets if needed
|
||||
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
|
||||
|
||||
logger.debug(
|
||||
f"DDP: Sending {len(pixels)} pixels ({total_bytes} bytes) "
|
||||
f"in {num_packets} packet(s) to {self.host}:{self.port}"
|
||||
)
|
||||
|
||||
for i in range(num_packets):
|
||||
start = i * bytes_per_packet
|
||||
end = min(start + bytes_per_packet, total_bytes)
|
||||
chunk = pixel_bytes[start:end]
|
||||
is_last = i == num_packets - 1
|
||||
|
||||
# Increment sequence number
|
||||
self._sequence = (self._sequence + 1) % 256
|
||||
|
||||
# Set PUSH flag on the last packet to signal frame completion
|
||||
packet = self._build_ddp_packet(
|
||||
chunk,
|
||||
offset=start,
|
||||
sequence=self._sequence,
|
||||
push=is_last,
|
||||
)
|
||||
self._transport.sendto(packet)
|
||||
|
||||
logger.debug(
|
||||
f"Sent DDP packet {i+1}/{num_packets}: "
|
||||
f"{len(chunk)} bytes at offset {start}"
|
||||
f"{' [PUSH]' if is_last else ''}"
|
||||
)
|
||||
|
||||
self.send_pixels_numpy(pixel_array, max_packet_size=max_packet_size)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send DDP pixels: {e}")
|
||||
logger.error("Failed to send DDP pixels: %s", e)
|
||||
raise RuntimeError(f"DDP send failed: {e}")
|
||||
|
||||
def send_pixels_numpy(self, pixel_array: np.ndarray, max_packet_size: int = 1400) -> bool:
|
||||
@@ -270,28 +255,15 @@ class DDPClient:
|
||||
self._rgbw_buf[:, :3] = pixel_array
|
||||
pixel_array = self._rgbw_buf
|
||||
|
||||
pixel_bytes = pixel_array.tobytes()
|
||||
|
||||
bpp = 4 if self.rgbw else 3
|
||||
total_bytes = len(pixel_bytes)
|
||||
max_payload = max_packet_size - 10 # 10-byte header
|
||||
bytes_per_packet = (max_payload // bpp) * bpp
|
||||
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
|
||||
|
||||
for i in range(num_packets):
|
||||
start = i * bytes_per_packet
|
||||
end = min(start + bytes_per_packet, total_bytes)
|
||||
chunk = pixel_bytes[start:end]
|
||||
is_last = i == num_packets - 1
|
||||
self._sequence = (self._sequence + 1) % 256
|
||||
packet = self._build_ddp_packet(
|
||||
chunk,
|
||||
offset=start,
|
||||
sequence=self._sequence,
|
||||
push=is_last,
|
||||
)
|
||||
self._transport.sendto(packet)
|
||||
|
||||
# Get a 1-D bytes view of the pixel buffer with no allocation when
|
||||
# the array is already C-contiguous (the common case).
|
||||
if not pixel_array.flags["C_CONTIGUOUS"]:
|
||||
pixel_array = np.ascontiguousarray(pixel_array)
|
||||
# ``cast('B')`` on a memoryview of a numpy array returns a 1-D byte
|
||||
# view; total length == nbytes.
|
||||
payload_view = memoryview(pixel_array).cast("B")
|
||||
self._send_buffer(payload_view, bpp, max_packet_size)
|
||||
return True
|
||||
|
||||
async def __aenter__(self):
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Background discovery watcher — long-running mDNS browser + serial port poller.
|
||||
|
||||
Existing per-target health monitoring (``DeviceHealthMixin``) already fires
|
||||
``device_health_changed`` events when *configured* devices flip online/offline.
|
||||
This module is the complementary half: it watches for *new* devices appearing
|
||||
on the LAN/USB (and old discovered-but-never-configured ones disappearing) and
|
||||
emits ``device_discovered`` / ``device_lost`` events on the same event bus.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- The mDNS browser is kept alive for the process lifetime (one ``AsyncZeroconf``
|
||||
+ ``AsyncServiceBrowser``), which is the entire point of "background discovery".
|
||||
The on-demand scan in ``WLEDDeviceProvider.discover`` is unchanged — that one
|
||||
spins up its own short-lived browser for the Add Device modal.
|
||||
- Serial-port hotplug has no equivalent of mDNS push, so we poll
|
||||
:func:`list_serial_ports` every 10 s. Cheap on desktop (one Windows API call),
|
||||
no-op on Android (handled separately by Kotlin USB receivers).
|
||||
- Already-configured devices (matched by URL or MAC against ``device_store``)
|
||||
are intentionally suppressed from the discovery stream — those are covered by
|
||||
the health-monitor's online/offline events and would otherwise notify twice
|
||||
per device on startup.
|
||||
|
||||
The watcher is purely an event source; pref-driven snack/OS-toast routing
|
||||
happens client-side in ``features/notifications-watcher.ts``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Callable, Dict, Optional
|
||||
|
||||
from zeroconf import ServiceStateChange
|
||||
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
||||
|
||||
from ledgrab.core.devices.serial_transport import list_serial_ports
|
||||
from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.platform import is_android
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.storage.device_store import DeviceStore
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Poll interval for serial-port enumeration. Cheap on desktop; skipped on Android.
|
||||
_SERIAL_POLL_INTERVAL_SEC = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DiscoveredEntry:
|
||||
"""A device the watcher has seen at least once.
|
||||
|
||||
Two snapshots are compared each cycle (mDNS by service name, serial by
|
||||
device path) to detect appearances vs. disappearances; the URL is what
|
||||
we cross-reference against ``device_store`` to know if the device is
|
||||
already configured.
|
||||
"""
|
||||
|
||||
key: str
|
||||
url: str
|
||||
name: str
|
||||
device_type: str # "wled" | "serial"
|
||||
|
||||
|
||||
FireEvent = Callable[[dict], None]
|
||||
|
||||
|
||||
class DiscoveryWatcher:
|
||||
"""Continuously scan for new WLED/serial devices and emit events."""
|
||||
|
||||
def __init__(self, device_store: "DeviceStore", fire_event: FireEvent) -> None:
|
||||
self._device_store = device_store
|
||||
self._fire_event = fire_event
|
||||
|
||||
self._aiozc: Optional[AsyncZeroconf] = None
|
||||
self._browser: Optional[AsyncServiceBrowser] = None
|
||||
self._serial_task: Optional[asyncio.Task] = None
|
||||
self._running = False
|
||||
self._started_at: float = 0.0
|
||||
|
||||
# service-name -> entry. mDNS state-change callback runs on the
|
||||
# asyncio thread so no lock is needed; Python attr writes are atomic.
|
||||
self._wled_seen: Dict[str, _DiscoveredEntry] = {}
|
||||
# device-path -> entry. Only the serial poller mutates this.
|
||||
self._serial_seen: Dict[str, _DiscoveredEntry] = {}
|
||||
|
||||
# --- lifecycle --------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._started_at = time.monotonic()
|
||||
|
||||
# mDNS browser — kept alive for the whole process. The handler is sync
|
||||
# (zeroconf calls it via call_soon on our loop), but resolves run in a
|
||||
# short-lived task to avoid blocking the dispatcher.
|
||||
try:
|
||||
self._aiozc = AsyncZeroconf()
|
||||
self._browser = AsyncServiceBrowser(
|
||||
self._aiozc.zeroconf,
|
||||
WLED_MDNS_TYPE,
|
||||
handlers=[self._on_wled_state_change],
|
||||
)
|
||||
logger.info("Discovery watcher: mDNS browser started for %s", WLED_MDNS_TYPE)
|
||||
except Exception as e:
|
||||
# Don't let a zeroconf failure (firewall, multiple-host, etc.)
|
||||
# prevent the rest of the server from coming up.
|
||||
logger.error("Discovery watcher: failed to start mDNS browser: %s", e)
|
||||
self._aiozc = None
|
||||
self._browser = None
|
||||
|
||||
# Serial poller — only on desktop. On Android, USB hotplug is delivered
|
||||
# through Kotlin receivers, not by polling pyserial.
|
||||
if not is_android():
|
||||
self._serial_task = asyncio.create_task(self._serial_poll_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
if self._serial_task is not None:
|
||||
self._serial_task.cancel()
|
||||
try:
|
||||
await self._serial_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._serial_task = None
|
||||
|
||||
if self._browser is not None:
|
||||
try:
|
||||
await self._browser.async_cancel()
|
||||
except Exception as e:
|
||||
logger.debug("Discovery watcher: browser cancel error: %s", e)
|
||||
self._browser = None
|
||||
|
||||
if self._aiozc is not None:
|
||||
try:
|
||||
await self._aiozc.async_close()
|
||||
except Exception as e:
|
||||
logger.debug("Discovery watcher: zeroconf close error: %s", e)
|
||||
self._aiozc = None
|
||||
|
||||
logger.info("Discovery watcher stopped")
|
||||
|
||||
# --- mDNS -------------------------------------------------------------
|
||||
|
||||
def _on_wled_state_change(self, **kwargs) -> None:
|
||||
"""zeroconf state-change handler. Runs on the asyncio thread."""
|
||||
state_change = kwargs.get("state_change")
|
||||
service_type = kwargs.get("service_type", "")
|
||||
name = kwargs.get("name", "")
|
||||
|
||||
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
|
||||
# Resolve in a task — async_request blocks the handler if awaited
|
||||
# synchronously and we don't want to stall mDNS dispatch.
|
||||
asyncio.create_task(self._resolve_wled(service_type, name))
|
||||
elif state_change == ServiceStateChange.Removed:
|
||||
entry = self._wled_seen.pop(name, None)
|
||||
if entry is not None and not self._is_configured(entry.url):
|
||||
self._emit("device_lost", entry)
|
||||
|
||||
async def _resolve_wled(self, service_type: str, name: str) -> None:
|
||||
if self._aiozc is None:
|
||||
return
|
||||
info = AsyncServiceInfo(service_type, name)
|
||||
try:
|
||||
await info.async_request(self._aiozc.zeroconf, timeout=2000)
|
||||
except Exception as e:
|
||||
logger.debug("Discovery watcher: resolve failed for %s: %s", name, e)
|
||||
return
|
||||
|
||||
addrs = info.parsed_addresses()
|
||||
if not addrs:
|
||||
return
|
||||
ip = addrs[0]
|
||||
port = info.port or 80
|
||||
url = f"http://{ip}" if port == 80 else f"http://{ip}:{port}"
|
||||
service_name = name.replace(f".{service_type}", "")
|
||||
|
||||
entry = _DiscoveredEntry(
|
||||
key=name,
|
||||
url=url,
|
||||
name=service_name,
|
||||
device_type="wled",
|
||||
)
|
||||
|
||||
first_sight = name not in self._wled_seen
|
||||
self._wled_seen[name] = entry
|
||||
|
||||
if first_sight and not self._is_configured(url):
|
||||
self._emit("device_discovered", entry)
|
||||
|
||||
# --- serial -----------------------------------------------------------
|
||||
|
||||
async def _serial_poll_loop(self) -> None:
|
||||
"""Detect serial-port appearances/disappearances on a fixed interval."""
|
||||
try:
|
||||
# Seed without notifying — ports already plugged in when the server
|
||||
# starts shouldn't generate "new device" toasts on every boot.
|
||||
for port in list_serial_ports():
|
||||
url = port.device
|
||||
self._serial_seen[url] = _DiscoveredEntry(
|
||||
key=url,
|
||||
url=url,
|
||||
name=port.description,
|
||||
device_type="serial",
|
||||
)
|
||||
|
||||
while self._running:
|
||||
await asyncio.sleep(_SERIAL_POLL_INTERVAL_SEC)
|
||||
if not self._running:
|
||||
break
|
||||
self._poll_serial_once()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("Discovery watcher: serial loop crashed: %s", e)
|
||||
|
||||
def _poll_serial_once(self) -> None:
|
||||
try:
|
||||
current = {p.device: p for p in list_serial_ports()}
|
||||
except Exception as e:
|
||||
logger.debug("Discovery watcher: serial enumeration failed: %s", e)
|
||||
return
|
||||
|
||||
# Appeared
|
||||
for device, port in current.items():
|
||||
if device in self._serial_seen:
|
||||
continue
|
||||
entry = _DiscoveredEntry(
|
||||
key=device,
|
||||
url=device,
|
||||
name=port.description,
|
||||
device_type="serial",
|
||||
)
|
||||
self._serial_seen[device] = entry
|
||||
if not self._is_configured(device):
|
||||
self._emit("device_discovered", entry)
|
||||
|
||||
# Disappeared
|
||||
for device in list(self._serial_seen.keys()):
|
||||
if device in current:
|
||||
continue
|
||||
entry = self._serial_seen.pop(device)
|
||||
if not self._is_configured(entry.url):
|
||||
self._emit("device_lost", entry)
|
||||
|
||||
# --- helpers ----------------------------------------------------------
|
||||
|
||||
def _is_configured(self, url: str) -> bool:
|
||||
"""True when the URL matches a device already in the user's store."""
|
||||
try:
|
||||
for device in self._device_store.get_all_devices():
|
||||
if device.url and device.url.rstrip("/") == url.rstrip("/"):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("Discovery watcher: device store lookup failed: %s", e)
|
||||
return False
|
||||
|
||||
def _emit(self, event_type: str, entry: _DiscoveredEntry) -> None:
|
||||
try:
|
||||
self._fire_event(
|
||||
{
|
||||
"type": event_type,
|
||||
"device_type": entry.device_type,
|
||||
"url": entry.url,
|
||||
"name": entry.name,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Discovery watcher: fire_event failed: %s", e)
|
||||
@@ -148,23 +148,37 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
"""Apply segment-based color updates to the buffer.
|
||||
|
||||
Each segment defines a range and fill mode. Segments are applied in
|
||||
order (last wins on overlap). The buffer is auto-grown if needed.
|
||||
order (last wins on overlap).
|
||||
|
||||
``start`` and ``length`` are optional: ``start`` defaults to 0,
|
||||
``length`` defaults to ``led_count - start`` (i.e. the remainder of
|
||||
the strip). The buffer is only auto-grown for segments that supply
|
||||
an explicit ``length`` extending past the current end — implicit
|
||||
"to the end" segments adapt to whatever the current strip size is.
|
||||
|
||||
Args:
|
||||
segments: list of dicts with keys:
|
||||
start (int) – starting LED index
|
||||
length (int) – number of LEDs in segment
|
||||
mode (str) – "solid" | "per_pixel" | "gradient"
|
||||
color (list) – [R,G,B] for solid mode
|
||||
colors (list) – [[R,G,B], ...] for per_pixel/gradient
|
||||
start (int, optional) – starting LED index (default 0)
|
||||
length (int, optional) – number of LEDs in segment
|
||||
(default = led_count - start)
|
||||
mode (str) – "solid" | "per_pixel" | "gradient"
|
||||
color (list) – [R,G,B] for solid mode
|
||||
colors (list) – [[R,G,B], ...] for per_pixel/gradient
|
||||
"""
|
||||
# Compute required buffer size from all segments
|
||||
max_index = max(seg["start"] + seg["length"] for seg in segments)
|
||||
# Compute required buffer size from segments that supply an explicit
|
||||
# length. Segments without a length take the strip as-is and so do
|
||||
# not trigger growth.
|
||||
explicit_max = 0
|
||||
for seg in segments:
|
||||
seg_start = seg.get("start") or 0
|
||||
seg_len = seg.get("length")
|
||||
if seg_len is not None:
|
||||
explicit_max = max(explicit_max, seg_start + seg_len)
|
||||
|
||||
with self._lock:
|
||||
# Auto-grow buffer if needed
|
||||
if max_index > self._led_count:
|
||||
self._ensure_capacity(max_index)
|
||||
# Auto-grow buffer if any explicit segment extends past current end
|
||||
if explicit_max > self._led_count:
|
||||
self._ensure_capacity(explicit_max)
|
||||
|
||||
# Start from current buffer (or fallback if timed out)
|
||||
if self._timed_out:
|
||||
@@ -173,8 +187,12 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
buf = self._colors.copy()
|
||||
|
||||
for seg in segments:
|
||||
start = seg["start"]
|
||||
length = seg["length"]
|
||||
seg_start = seg.get("start")
|
||||
start = 0 if seg_start is None else seg_start
|
||||
seg_len = seg.get("length")
|
||||
length = max(0, self._led_count - start) if seg_len is None else seg_len
|
||||
if length <= 0:
|
||||
continue
|
||||
mode = seg["mode"]
|
||||
end = start + length
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ via the shim module ``color_strip_stream.py``.
|
||||
"""
|
||||
|
||||
from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise
|
||||
from .cycle import ColorCycleColorStripStream
|
||||
from .gradient import GradientColorStripStream
|
||||
from .helpers import _compute_gradient_colors
|
||||
from .picture import PictureColorStripStream
|
||||
@@ -16,7 +15,6 @@ __all__ = [
|
||||
"ColorStripStream",
|
||||
"PictureColorStripStream",
|
||||
"StaticColorStripStream",
|
||||
"ColorCycleColorStripStream",
|
||||
"GradientColorStripStream",
|
||||
"_compute_gradient_colors",
|
||||
"_SimpleNoise1D",
|
||||
|
||||
@@ -45,6 +45,19 @@ class ColorStripStream(ABC):
|
||||
def target_fps(self) -> int:
|
||||
"""Target processing rate."""
|
||||
|
||||
@property
|
||||
def actual_fps(self) -> Optional[float]:
|
||||
"""Measured rate of *new* frames the stream is delivering, or ``None``.
|
||||
|
||||
Only streams backed by an external capture (screen, audio device, API
|
||||
push) implement this — the value answers "is the upstream actually
|
||||
keeping up?". Synthetic streams (gradient/static/cycle/effect/...)
|
||||
always tick at their `target_fps` by construction, so reporting an
|
||||
actual rate would just duplicate `target_fps` without diagnostic
|
||||
value; they keep the default ``None``.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def led_count(self) -> int:
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Color cycle stream — smoothly cycles through user-defined colors."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.frame_limiter import FrameLimiter
|
||||
from ledgrab.utils.timer import high_resolution_timer
|
||||
|
||||
from .base import ColorStripStream
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ColorCycleColorStripStream(ColorStripStream):
|
||||
"""Color strip stream that smoothly cycles through a user-defined color list.
|
||||
|
||||
All LEDs receive the same solid color at any moment, continuously interpolating
|
||||
between the configured colors in a loop.
|
||||
|
||||
LED count auto-sizes from the connected device when led_count == 0 in
|
||||
the source config; configure(device_led_count) is called by
|
||||
WledTargetProcessor on start.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
self._colors_lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._fps = 30
|
||||
self._frame_time = 1.0 / 30
|
||||
self._clock = None # optional SyncClockRuntime
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
raw = source.colors if isinstance(source.colors, list) else []
|
||||
default = [
|
||||
[255, 0, 0],
|
||||
[255, 255, 0],
|
||||
[0, 255, 0],
|
||||
[0, 255, 255],
|
||||
[0, 0, 255],
|
||||
[255, 0, 255],
|
||||
]
|
||||
self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default
|
||||
_lc = getattr(source, "led_count", 0)
|
||||
self._auto_size = not _lc
|
||||
self._led_count = _lc if _lc > 0 else 1
|
||||
self._rebuild_colors()
|
||||
|
||||
def _rebuild_colors(self) -> None:
|
||||
pixel = np.array(self._color_list[0], dtype=np.uint8)
|
||||
colors = np.tile(pixel, (self._led_count, 1))
|
||||
with self._colors_lock:
|
||||
self._colors = colors
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
"""Size to device LED count when led_count was 0 (auto-size)."""
|
||||
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
|
||||
self._led_count = device_led_count
|
||||
self._rebuild_colors()
|
||||
logger.debug(f"ColorCycleColorStripStream auto-sized to {device_led_count} LEDs")
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
return self._fps
|
||||
|
||||
@property
|
||||
def led_count(self) -> int:
|
||||
return self._led_count
|
||||
|
||||
def set_capture_fps(self, fps: int) -> None:
|
||||
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
|
||||
fps = max(1, min(90, fps))
|
||||
self._fps = fps
|
||||
self._frame_time = 1.0 / fps
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._animate_loop,
|
||||
name="css-color-cycle",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(
|
||||
f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})"
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5.0)
|
||||
if self._thread.is_alive():
|
||||
logger.warning(
|
||||
"ColorCycleColorStripStream animate thread did not terminate within 5s"
|
||||
)
|
||||
self._thread = None
|
||||
logger.info("ColorCycleColorStripStream stopped")
|
||||
|
||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||
with self._colors_lock:
|
||||
return self._colors
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
from ledgrab.storage.color_strip_source import ColorCycleColorStripSource
|
||||
|
||||
if isinstance(source, ColorCycleColorStripSource):
|
||||
prev_led_count = self._led_count if self._auto_size else None
|
||||
self._update_from_source(source)
|
||||
if prev_led_count and self._auto_size:
|
||||
self._led_count = prev_led_count
|
||||
self._rebuild_colors()
|
||||
logger.info("ColorCycleColorStripStream params updated in-place")
|
||||
|
||||
def set_clock(self, clock) -> None:
|
||||
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
|
||||
self._clock = clock
|
||||
|
||||
def _animate_loop(self) -> None:
|
||||
"""Background thread: interpolate between colors at target fps.
|
||||
|
||||
Uses double-buffered output arrays to avoid per-frame allocations.
|
||||
"""
|
||||
_pool_n = 0
|
||||
_buf_a = _buf_b = None
|
||||
_use_a = True
|
||||
|
||||
limiter = FrameLimiter(self._fps)
|
||||
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
while self._running:
|
||||
limiter.begin()
|
||||
wall_start = time.perf_counter()
|
||||
frame_time = self._frame_time
|
||||
try:
|
||||
color_list = self._color_list
|
||||
clock = self._clock
|
||||
if clock:
|
||||
if not clock.is_running:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
speed = clock.speed
|
||||
t = clock.get_time()
|
||||
else:
|
||||
speed = 1.0
|
||||
t = wall_start
|
||||
n = self._led_count
|
||||
num = len(color_list)
|
||||
if num >= 2:
|
||||
if n != _pool_n:
|
||||
_pool_n = n
|
||||
_buf_a = np.empty((n, 3), dtype=np.uint8)
|
||||
_buf_b = np.empty((n, 3), dtype=np.uint8)
|
||||
|
||||
buf = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
|
||||
# 0.05 factor → one full cycle every 20s at speed=1.0
|
||||
cycle_pos = (speed * t * 0.05) % 1.0
|
||||
seg = cycle_pos * num
|
||||
idx = int(seg) % num
|
||||
t_i = seg - int(seg)
|
||||
c1 = color_list[idx]
|
||||
c2 = color_list[(idx + 1) % num]
|
||||
buf[:] = (
|
||||
min(255, int(c1[0] + (c2[0] - c1[0]) * t_i)),
|
||||
min(255, int(c1[1] + (c2[1] - c1[1]) * t_i)),
|
||||
min(255, int(c1[2] + (c2[2] - c1[2]) * t_i)),
|
||||
)
|
||||
with self._colors_lock:
|
||||
self._colors = buf
|
||||
except Exception as e:
|
||||
logger.error(f"ColorCycleColorStripStream animation error: {e}")
|
||||
limiter.wait(frame_time)
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True)
|
||||
finally:
|
||||
self._running = False
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -72,6 +73,15 @@ class PictureColorStripStream(ColorStripStream):
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._last_timing: dict = {}
|
||||
|
||||
# Rolling 1s window of timestamps for *new* frames received from
|
||||
# the live stream. `len(...)` is the per-second frame rate the
|
||||
# picture pipeline is actually consuming — diverges from
|
||||
# `target_fps` when the underlying screen capture stalls (heavy
|
||||
# GPU load, occluded window, DXGI desktop switch, etc.). Reads
|
||||
# from another thread see a stale length at worst; deque ops are
|
||||
# atomic under the GIL so no lock is needed.
|
||||
self._new_frame_timestamps: deque[float] = deque(maxlen=180)
|
||||
|
||||
@property
|
||||
def live_stream(self):
|
||||
"""Public accessor for the underlying LiveStream (used by preview WebSocket)."""
|
||||
@@ -81,6 +91,31 @@ class PictureColorStripStream(ColorStripStream):
|
||||
def target_fps(self) -> int:
|
||||
return self._fps
|
||||
|
||||
@property
|
||||
def actual_fps(self) -> Optional[float]:
|
||||
"""Measured new-frame rate over the last 1 second.
|
||||
|
||||
Returns the count of distinct frames the picture loop accepted in
|
||||
the trailing 1s window. ``None`` until the loop has run (no
|
||||
meaningful number to report yet).
|
||||
"""
|
||||
ts_dq = self._new_frame_timestamps
|
||||
if not ts_dq:
|
||||
return None
|
||||
# Stale-tolerant read: producer may pop while we iterate, but we
|
||||
# only look at the snapshot length and the leftmost timestamp.
|
||||
now = time.perf_counter()
|
||||
# If the stream has gone idle (no new frames for >1s) the deque
|
||||
# still holds samples until the loop next ticks; report 0 so the
|
||||
# spark drops to the floor instead of pinning at the last rate.
|
||||
try:
|
||||
oldest = ts_dq[0]
|
||||
except IndexError:
|
||||
return None
|
||||
if now - oldest > 1.5:
|
||||
return 0.0
|
||||
return float(len(ts_dq))
|
||||
|
||||
@property
|
||||
def led_count(self) -> int:
|
||||
return self._led_count
|
||||
@@ -116,6 +151,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
self._thread = None
|
||||
self._latest_colors = None
|
||||
self._previous_colors = None
|
||||
self._new_frame_timestamps.clear()
|
||||
logger.info("PictureColorStripStream stopped")
|
||||
|
||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||
@@ -206,6 +242,14 @@ class PictureColorStripStream(ColorStripStream):
|
||||
cached_frame = frame
|
||||
|
||||
t0 = time.perf_counter()
|
||||
# Record the new frame in the rolling 1s window
|
||||
# used by `actual_fps`. Pop entries older than
|
||||
# 1s so `len()` reads as frames-per-second.
|
||||
ts_dq = self._new_frame_timestamps
|
||||
ts_dq.append(t0)
|
||||
cutoff = t0 - 1.0
|
||||
while ts_dq and ts_dq[0] < cutoff:
|
||||
ts_dq.popleft()
|
||||
|
||||
calibration = self._calibration
|
||||
mapper = self._pixel_mapper
|
||||
|
||||
@@ -73,7 +73,14 @@ class StaticColorStripStream(ColorStripStream):
|
||||
@property
|
||||
def is_animated(self) -> bool:
|
||||
anim = self._animation
|
||||
return bool(anim and anim.get("enabled"))
|
||||
if anim and anim.get("enabled"):
|
||||
return True
|
||||
return self._is_color_bound()
|
||||
|
||||
def _is_color_bound(self) -> bool:
|
||||
"""True when the `color` property is driven by a ValueStream."""
|
||||
vs = self._value_streams
|
||||
return bool(vs and vs.get("color"))
|
||||
|
||||
@property
|
||||
def led_count(self) -> int:
|
||||
@@ -243,10 +250,28 @@ class StaticColorStripStream(ColorStripStream):
|
||||
if colors is not None:
|
||||
with self._colors_lock:
|
||||
self._colors = colors
|
||||
elif self._is_color_bound():
|
||||
# No animation, but color is driven by a ValueStream —
|
||||
# poll and forward live color updates so the bound
|
||||
# source is honoured (otherwise LEDs stay stuck on
|
||||
# the static fallback).
|
||||
n = self._led_count
|
||||
if n != _pool_n:
|
||||
_pool_n = n
|
||||
_buf_a = np.empty((n, 3), dtype=np.uint8)
|
||||
_buf_b = np.empty((n, 3), dtype=np.uint8)
|
||||
buf = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
buf[:] = self.resolve_color("color", self._source_color)
|
||||
with self._colors_lock:
|
||||
self._colors = buf
|
||||
except Exception as e:
|
||||
logger.error(f"StaticColorStripStream animation error: {e}")
|
||||
|
||||
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
|
||||
if (anim and anim.get("enabled")) or self._is_color_bound():
|
||||
sleep_target = frame_time
|
||||
else:
|
||||
sleep_target = 0.25
|
||||
limiter.wait(sleep_target)
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
|
||||
|
||||
@@ -6,7 +6,6 @@ import path ``from ledgrab.core.processing.color_strip_stream import X``.
|
||||
"""
|
||||
|
||||
from ledgrab.core.processing.color_strip import ( # noqa: F401
|
||||
ColorCycleColorStripStream,
|
||||
ColorStripStream,
|
||||
GradientColorStripStream,
|
||||
PictureColorStripStream,
|
||||
@@ -20,7 +19,6 @@ __all__ = [
|
||||
"ColorStripStream",
|
||||
"PictureColorStripStream",
|
||||
"StaticColorStripStream",
|
||||
"ColorCycleColorStripStream",
|
||||
"GradientColorStripStream",
|
||||
"_compute_gradient_colors",
|
||||
"_SimpleNoise1D",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
PictureColorStripStreams (expensive screen capture) are shared across multiple
|
||||
consumers via reference counting — processing runs once, not once per target.
|
||||
|
||||
Count-dependent streams (static, gradient, color cycle, effect) are NOT shared.
|
||||
Count-dependent streams (static, gradient, effect) are NOT shared.
|
||||
Each consumer gets its own instance so it can configure an independent LED count
|
||||
without interfering with other targets.
|
||||
"""
|
||||
@@ -12,7 +12,6 @@ from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ledgrab.core.processing.color_strip_stream import (
|
||||
ColorCycleColorStripStream,
|
||||
ColorStripStream,
|
||||
GradientColorStripStream,
|
||||
PictureColorStripStream,
|
||||
@@ -34,7 +33,6 @@ logger = get_logger(__name__)
|
||||
_SIMPLE_STREAM_MAP = {
|
||||
"static": StaticColorStripStream,
|
||||
"gradient": GradientColorStripStream,
|
||||
"color_cycle": ColorCycleColorStripStream,
|
||||
"effect": EffectColorStripStream,
|
||||
"api_input": ApiInputColorStripStream,
|
||||
"notification": NotificationColorStripStream,
|
||||
|
||||
@@ -97,6 +97,30 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
def target_fps(self) -> int:
|
||||
return self._fps
|
||||
|
||||
@property
|
||||
def actual_fps(self) -> Optional[float]:
|
||||
"""Aggregate measured capture rate across capture-backed sub-streams.
|
||||
|
||||
Sums `actual_fps` from each sub-stream that reports one (i.e.
|
||||
capture-backed layers like screen/audio captures). Returns
|
||||
``None`` when no sub-stream measures capture — keeps synthetic-
|
||||
only composites out of the "Total Capture FPS" cell instead of
|
||||
contributing a 0.
|
||||
"""
|
||||
with self._sub_lock:
|
||||
subs = list(self._sub_streams.values())
|
||||
total = 0.0
|
||||
any_reporting = False
|
||||
for _src_id, _consumer_id, stream in subs:
|
||||
try:
|
||||
v = getattr(stream, "actual_fps", None)
|
||||
except Exception:
|
||||
v = None
|
||||
if isinstance(v, (int, float)):
|
||||
total += float(v)
|
||||
any_reporting = True
|
||||
return total if any_reporting else None
|
||||
|
||||
def set_capture_fps(self, fps: int) -> None:
|
||||
self._fps = max(1, min(90, fps))
|
||||
self._frame_time = 1.0 / self._fps
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Global daylight cycle preferences.
|
||||
|
||||
A single timezone applies to every daylight value source / color strip
|
||||
source on the server, so it lives in the key/value settings table rather
|
||||
than on each entity. The daylight streams read it on every wall-clock
|
||||
sample (cheap dict lookup with a short cache window) so changing it in
|
||||
the UI takes effect within ~1 second.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DAYLIGHT_TIMEZONE_KEY = "daylight_timezone"
|
||||
_CACHE_TTL_SECONDS = 1.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cached_tz: str = ""
|
||||
_cached_at: float = 0.0
|
||||
|
||||
|
||||
def _read_from_db() -> str:
|
||||
"""Read the persisted timezone from the settings table.
|
||||
|
||||
Returns an empty string when unset, the table is unavailable, or
|
||||
the stored value is corrupt — empty means "use system local time".
|
||||
"""
|
||||
try:
|
||||
from ledgrab.api.dependencies import get_database
|
||||
|
||||
raw = get_database().get_setting(DAYLIGHT_TIMEZONE_KEY)
|
||||
except Exception as e: # pragma: no cover — DB not initialised yet, e.g. in tests
|
||||
logger.debug("daylight timezone DB read failed: %s", e)
|
||||
return ""
|
||||
if not isinstance(raw, dict):
|
||||
return ""
|
||||
value = raw.get("value")
|
||||
return str(value) if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def get_daylight_timezone() -> str:
|
||||
"""Return the configured global daylight timezone (cached briefly)."""
|
||||
global _cached_tz, _cached_at
|
||||
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
if now - _cached_at < _CACHE_TTL_SECONDS:
|
||||
return _cached_tz
|
||||
|
||||
fresh = _read_from_db()
|
||||
with _lock:
|
||||
_cached_tz = fresh
|
||||
_cached_at = now
|
||||
return fresh
|
||||
|
||||
|
||||
def set_daylight_timezone(tz: Optional[str]) -> str:
|
||||
"""Persist the global daylight timezone and refresh the cache.
|
||||
|
||||
Returns the canonicalised stored value (empty string for None / blank).
|
||||
"""
|
||||
canonical = str(tz or "").strip()
|
||||
try:
|
||||
from ledgrab.api.dependencies import get_database
|
||||
|
||||
get_database().set_setting(DAYLIGHT_TIMEZONE_KEY, {"value": canonical})
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist daylight timezone: %s", e)
|
||||
|
||||
global _cached_tz, _cached_at
|
||||
with _lock:
|
||||
_cached_tz = canonical
|
||||
_cached_at = time.monotonic()
|
||||
return canonical
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
"""Force the next ``get_daylight_timezone`` call to re-read from DB."""
|
||||
global _cached_at
|
||||
with _lock:
|
||||
_cached_at = 0.0
|
||||
@@ -22,8 +22,33 @@ from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.frame_limiter import FrameLimiter
|
||||
from ledgrab.utils.timer import high_resolution_timer
|
||||
|
||||
try:
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
except ImportError: # pragma: no cover — pre-3.9 fallback, not expected in target envs
|
||||
ZoneInfo = None # type: ignore[assignment]
|
||||
|
||||
class ZoneInfoNotFoundError(Exception): # type: ignore[no-redef]
|
||||
pass
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _now_in_tz(tz_name: str) -> datetime.datetime:
|
||||
"""Return current wall-clock time in the named IANA timezone.
|
||||
|
||||
Empty string means "use system local time". Unknown timezones fall
|
||||
back to local time and log a warning once per unknown name.
|
||||
"""
|
||||
if not tz_name or ZoneInfo is None:
|
||||
return datetime.datetime.now()
|
||||
try:
|
||||
return datetime.datetime.now(ZoneInfo(tz_name))
|
||||
except ZoneInfoNotFoundError:
|
||||
logger.warning(f"Unknown daylight timezone '{tz_name}' — falling back to system local")
|
||||
return datetime.datetime.now()
|
||||
|
||||
|
||||
# ── Daylight color table ────────────────────────────────────────────────
|
||||
#
|
||||
# Canonical hour control points (0–24) → RGB. Designed for a default
|
||||
@@ -62,13 +87,19 @@ _daylight_lut: Optional[np.ndarray] = None
|
||||
# ── Solar position helpers ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) -> tuple:
|
||||
"""Return (sunrise_hour, sunset_hour) in local solar time.
|
||||
def _compute_solar_times(
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
day_of_year: int,
|
||||
utc_offset_hours: float = 0.0,
|
||||
) -> tuple:
|
||||
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time.
|
||||
|
||||
Uses simplified NOAA solar equations:
|
||||
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
|
||||
- hour angle: cos(ha) = -tan(lat) * tan(decl)
|
||||
- sunrise/sunset: 12 ∓ ha/15, shifted by longitude
|
||||
- solar noon (UTC): 12 - longitude/15
|
||||
- wall-clock sunrise/sunset: solar_noon_utc + utc_offset ∓ ha/15
|
||||
|
||||
Polar day and polar night are clamped to visible ranges.
|
||||
"""
|
||||
@@ -79,28 +110,48 @@ def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) ->
|
||||
lat_rad = latitude * deg2rad
|
||||
|
||||
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
|
||||
solar_noon_utc = 12.0 - longitude / 15.0
|
||||
solar_noon_local = solar_noon_utc + utc_offset_hours
|
||||
|
||||
if cos_ha <= -1.0:
|
||||
# Polar day — sun never sets
|
||||
sunrise = 3.0
|
||||
sunset = 21.0
|
||||
# Polar day — sun never sets; fake a long visible window
|
||||
sunrise = solar_noon_local - 9.0
|
||||
sunset = solar_noon_local + 9.0
|
||||
elif cos_ha >= 1.0:
|
||||
# Polar night — sun never rises
|
||||
sunrise = 12.0
|
||||
sunset = 12.0
|
||||
# Polar night — sun never rises; collapse to noon
|
||||
sunrise = solar_noon_local
|
||||
sunset = solar_noon_local
|
||||
else:
|
||||
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
|
||||
lon_offset = longitude / 15.0
|
||||
solar_noon = 12.0 - lon_offset
|
||||
sunrise = solar_noon - ha_hours
|
||||
sunset = solar_noon + ha_hours
|
||||
sunrise = solar_noon_local - ha_hours
|
||||
sunset = solar_noon_local + ha_hours
|
||||
|
||||
# Clamp to sane ranges
|
||||
sunrise = max(3.0, min(10.0, sunrise))
|
||||
sunset = max(14.0, min(21.0, sunset))
|
||||
# Clamp to a safe range the LUT builder can render. With reasonable
|
||||
# tz/longitude pairs sunrise lands in (3..10) and sunset in (14..21);
|
||||
# we widen the clamp so weird tz/lon combinations still produce a
|
||||
# usable curve instead of dividing by zero.
|
||||
sunrise = max(0.5, min(11.5, sunrise))
|
||||
sunset = max(12.5, min(23.5, sunset))
|
||||
return sunrise, sunset
|
||||
|
||||
|
||||
def _utc_offset_hours_for(tz_name: str, when: Optional[datetime.datetime] = None) -> float:
|
||||
"""Return the UTC offset (in hours) for the given IANA timezone.
|
||||
|
||||
Empty/unknown tz falls back to the system local offset for ``when``.
|
||||
"""
|
||||
when = when or datetime.datetime.now()
|
||||
if tz_name and ZoneInfo is not None:
|
||||
try:
|
||||
offset = when.replace(tzinfo=None).astimezone(ZoneInfo(tz_name)).utcoffset()
|
||||
if offset is not None:
|
||||
return offset.total_seconds() / 3600.0
|
||||
except ZoneInfoNotFoundError:
|
||||
pass
|
||||
local_offset = when.astimezone().utcoffset()
|
||||
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
|
||||
|
||||
|
||||
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
|
||||
"""Build a 1440-entry uint8 RGB LUT scaled to the given sunrise/sunset hours.
|
||||
|
||||
@@ -198,9 +249,11 @@ class DaylightColorStripStream(ColorStripStream):
|
||||
with self._colors_lock:
|
||||
self._colors: Optional[np.ndarray] = None
|
||||
|
||||
def _get_lut_for_day(self, day_of_year: int) -> np.ndarray:
|
||||
def _get_lut_for_day(self, day_of_year: int, utc_offset_hours: float = 0.0) -> np.ndarray:
|
||||
"""Return a solar-time-aware LUT for the given day (cached)."""
|
||||
sunrise, sunset = _compute_solar_times(self._latitude, self._longitude, day_of_year)
|
||||
sunrise, sunset = _compute_solar_times(
|
||||
self._latitude, self._longitude, day_of_year, utc_offset_hours
|
||||
)
|
||||
sr_key = int(round(sunrise * 60))
|
||||
ss_key = int(round(sunset * 60))
|
||||
cache_key = (sr_key, ss_key)
|
||||
@@ -304,10 +357,16 @@ class DaylightColorStripStream(ColorStripStream):
|
||||
buf = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
|
||||
from ledgrab.core.processing.daylight_settings import (
|
||||
get_daylight_timezone,
|
||||
)
|
||||
|
||||
tz_name = get_daylight_timezone()
|
||||
if self._use_real_time:
|
||||
now = datetime.datetime.now()
|
||||
now = _now_in_tz(tz_name)
|
||||
day_of_year = now.timetuple().tm_yday
|
||||
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
|
||||
utc_offset_hours = _utc_offset_hours_for(tz_name, now)
|
||||
else:
|
||||
# Simulated: speed=1.0 → full 24h in 240s.
|
||||
# Use summer solstice (day 172) for maximum day length.
|
||||
@@ -315,8 +374,9 @@ class DaylightColorStripStream(ColorStripStream):
|
||||
cycle_seconds = 240.0 / max(speed, 0.01)
|
||||
phase = (t % cycle_seconds) / cycle_seconds
|
||||
minute_of_day = phase * 1440.0
|
||||
utc_offset_hours = _utc_offset_hours_for(tz_name)
|
||||
|
||||
lut = self._get_lut_for_day(day_of_year)
|
||||
lut = self._get_lut_for_day(day_of_year, utc_offset_hours)
|
||||
idx = int(minute_of_day) % 1440
|
||||
color = lut[idx]
|
||||
buf[:] = color
|
||||
|
||||
@@ -26,7 +26,9 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
self,
|
||||
target_id: str,
|
||||
ha_source_id: str,
|
||||
source_kind: str = "css",
|
||||
color_strip_source_id: str = "",
|
||||
color_value_source_id: str = "",
|
||||
brightness=None,
|
||||
# legacy compat
|
||||
brightness_value_source_id: str = "",
|
||||
@@ -35,13 +37,16 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
transition=None,
|
||||
min_brightness_threshold: int = 0,
|
||||
color_tolerance: int = 5,
|
||||
stop_action: str = "none",
|
||||
ctx: Optional[TargetContext] = None,
|
||||
):
|
||||
from ledgrab.storage.bindable import BindableFloat, bfloat
|
||||
|
||||
super().__init__(target_id, ctx)
|
||||
self._ha_source_id = ha_source_id
|
||||
self._source_kind = source_kind if source_kind in ("css", "color_vs") else "css"
|
||||
self._css_id = color_strip_source_id
|
||||
self._color_vs_id = color_value_source_id
|
||||
# Accept BindableFloat or legacy string
|
||||
if brightness is not None and isinstance(brightness, BindableFloat):
|
||||
self._brightness = brightness
|
||||
@@ -56,14 +61,20 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
self._update_rate = max(0.5, min(5.0, bfloat(update_rate, 2.0)))
|
||||
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
|
||||
self._color_tolerance = int(bfloat(color_tolerance, 5.0))
|
||||
self._stop_action = (
|
||||
stop_action if stop_action in ("none", "turn_off", "restore") else "none"
|
||||
)
|
||||
|
||||
# Runtime state
|
||||
self._css_stream = None
|
||||
self._color_stream = None # color-returning ValueStream (source_kind="color_vs")
|
||||
self._ha_runtime = None
|
||||
self._value_stream = None # brightness value source stream
|
||||
self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
|
||||
self._previous_on: Dict[str, bool] = {} # track on/off state per entity
|
||||
self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {}
|
||||
# Snapshot of entity states captured at start() — used by "restore" stop action
|
||||
self._captured_states: Dict[str, Any] = {}
|
||||
self._ws_clients: List[Any] = []
|
||||
self._start_time: Optional[float] = None
|
||||
|
||||
@@ -75,14 +86,23 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
if self._is_running:
|
||||
return
|
||||
|
||||
# Acquire CSS stream
|
||||
if self._css_id and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
|
||||
self._css_id, self._target_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"HA light {self._target_id}: failed to acquire CSS stream: {e}")
|
||||
# Acquire colour source — CSS stream OR colour value stream depending on mode.
|
||||
if self._source_kind == "color_vs":
|
||||
if self._color_vs_id and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"HA light {self._target_id}: failed to acquire color VS stream: {e}"
|
||||
)
|
||||
else:
|
||||
if self._css_id and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
|
||||
self._css_id, self._target_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"HA light {self._target_id}: failed to acquire CSS stream: {e}")
|
||||
|
||||
# Acquire HA runtime
|
||||
try:
|
||||
@@ -104,6 +124,10 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}")
|
||||
self._value_stream = None
|
||||
|
||||
# Capture initial entity states for "restore" stop action.
|
||||
# We always capture (cheap) so changing stop_action while running still works.
|
||||
self._captured_states = self._snapshot_mapped_entity_states()
|
||||
|
||||
self._is_running = True
|
||||
self._start_time = time.monotonic()
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
@@ -119,6 +143,14 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
# Run finalization (turn_off / restore) before releasing the HA runtime.
|
||||
try:
|
||||
await self._apply_stop_action()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"HA light {self._target_id}: stop_action '{self._stop_action}' failed: {e}"
|
||||
)
|
||||
|
||||
# Release CSS stream
|
||||
if self._css_stream and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
@@ -127,6 +159,14 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
pass
|
||||
self._css_stream = None
|
||||
|
||||
# Release colour value stream (color_vs mode)
|
||||
if self._color_stream is not None and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._ctx.value_stream_manager.release(self._color_vs_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._color_stream = None
|
||||
|
||||
# Release brightness value stream
|
||||
if self._value_stream is not None and self._ctx.value_stream_manager:
|
||||
try:
|
||||
@@ -148,6 +188,7 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
self._previous_colors.clear()
|
||||
self._previous_on.clear()
|
||||
self._latest_entity_colors.clear()
|
||||
self._captured_states.clear()
|
||||
self._ws_clients.clear()
|
||||
logger.info(f"HA light target stopped: {self._target_id}")
|
||||
|
||||
@@ -177,13 +218,30 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0))
|
||||
if "light_mappings" in settings:
|
||||
self._light_mappings = settings["light_mappings"]
|
||||
if "stop_action" in settings:
|
||||
sa = settings["stop_action"]
|
||||
if sa in ("none", "turn_off", "restore"):
|
||||
self._stop_action = sa
|
||||
# source_kind / color_value_source_id swap is handled here so that
|
||||
# toggling modes (or repointing the colour VS) takes effect without
|
||||
# restarting the target. CSS swaps continue to flow through
|
||||
# update_css_source().
|
||||
new_kind = settings.get("source_kind")
|
||||
new_color_vs = settings.get("color_value_source_id")
|
||||
kind_changed = new_kind in ("css", "color_vs") and new_kind != self._source_kind
|
||||
color_vs_changed = new_color_vs is not None and new_color_vs != self._color_vs_id
|
||||
if kind_changed or color_vs_changed:
|
||||
self._swap_color_source(
|
||||
new_kind if kind_changed else self._source_kind,
|
||||
new_color_vs if new_color_vs is not None else self._color_vs_id,
|
||||
)
|
||||
|
||||
def update_css_source(self, color_strip_source_id: str) -> None:
|
||||
"""Hot-swap the CSS stream."""
|
||||
"""Hot-swap the CSS stream (only meaningful when source_kind='css')."""
|
||||
old_id = self._css_id
|
||||
self._css_id = color_strip_source_id
|
||||
|
||||
if self._is_running and self._ctx.color_strip_stream_manager:
|
||||
if self._source_kind == "css" and self._is_running and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
new_stream = self._ctx.color_strip_stream_manager.acquire(
|
||||
color_strip_source_id, self._target_id
|
||||
@@ -195,6 +253,52 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
except Exception as e:
|
||||
logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}")
|
||||
|
||||
def _swap_color_source(self, new_kind: str, new_color_vs_id: str) -> None:
|
||||
"""Release the previous colour stream and acquire the new one."""
|
||||
# Tear down previous stream first to keep ref-counts honest.
|
||||
if self._is_running:
|
||||
if self._css_stream and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._css_stream = None
|
||||
if self._color_stream is not None and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._ctx.value_stream_manager.release(self._color_vs_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._color_stream = None
|
||||
|
||||
self._source_kind = new_kind
|
||||
self._color_vs_id = new_color_vs_id
|
||||
|
||||
# Reset per-entity history so the new source isn't gated by stale values.
|
||||
self._previous_colors.clear()
|
||||
self._previous_on.clear()
|
||||
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
if self._source_kind == "color_vs":
|
||||
if self._color_vs_id and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"HA light {self._target_id}: failed to acquire color VS stream: {e}"
|
||||
)
|
||||
else:
|
||||
if self._css_id and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
|
||||
self._css_id, self._target_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"HA light {self._target_id}: failed to re-acquire CSS stream: {e}"
|
||||
)
|
||||
|
||||
# ── WebSocket clients ──
|
||||
|
||||
def add_ws_client(self, ws: Any) -> None:
|
||||
@@ -217,13 +321,16 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
"target_id": self._target_id,
|
||||
"processing": self._is_running,
|
||||
"ha_source_id": self._ha_source_id,
|
||||
"source_kind": self._source_kind,
|
||||
"css_id": self._css_id,
|
||||
"color_value_source_id": self._color_vs_id,
|
||||
"is_running": self._is_running,
|
||||
"ha_connected": self._ha_runtime.is_connected if self._ha_runtime else False,
|
||||
"light_count": len(self._light_mappings),
|
||||
"update_rate": self._update_rate,
|
||||
"fps_actual": self._update_rate if self._is_running else None,
|
||||
"fps_target": self._update_rate,
|
||||
"fps_capture": self._update_rate if self._is_running else None,
|
||||
"uptime_seconds": uptime,
|
||||
"entity_colors": entity_colors,
|
||||
}
|
||||
@@ -243,17 +350,28 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
}
|
||||
|
||||
async def _processing_loop(self) -> None:
|
||||
"""Main loop: read CSS colors, average per mapping, send to HA lights."""
|
||||
"""Main loop: read source colour(s) and send to HA lights."""
|
||||
interval = 1.0 / self._update_rate
|
||||
|
||||
while self._is_running:
|
||||
try:
|
||||
loop_start = time.monotonic()
|
||||
|
||||
if self._css_stream and self._ha_runtime and self._ha_runtime.is_connected:
|
||||
colors = self._css_stream.get_latest_colors()
|
||||
if colors is not None and len(colors) > 0:
|
||||
await self._update_lights(colors)
|
||||
ha_ready = self._ha_runtime and self._ha_runtime.is_connected
|
||||
if ha_ready:
|
||||
if self._source_kind == "color_vs" and self._color_stream is not None:
|
||||
try:
|
||||
color = self._color_stream.get_color()
|
||||
except Exception:
|
||||
color = None
|
||||
if isinstance(color, (list, tuple)) and len(color) >= 3:
|
||||
await self._update_lights_single_color(
|
||||
int(color[0]), int(color[1]), int(color[2])
|
||||
)
|
||||
elif self._css_stream is not None:
|
||||
colors = self._css_stream.get_latest_colors()
|
||||
if colors is not None and len(colors) > 0:
|
||||
await self._update_lights(colors)
|
||||
|
||||
# Sleep for remaining frame time
|
||||
elapsed = time.monotonic() - loop_start
|
||||
@@ -266,99 +384,110 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
logger.error(f"HA light {self._target_id} loop error: {e}")
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
async def _update_lights(self, colors: np.ndarray) -> None:
|
||||
"""Average LED segments and call HA services for changed lights."""
|
||||
led_count = len(colors)
|
||||
def _read_brightness_multiplier(self) -> float:
|
||||
if self._value_stream is None:
|
||||
return 1.0
|
||||
try:
|
||||
return float(self._value_stream.get_value())
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
# Get brightness multiplier from value source (1.0 if not configured)
|
||||
vs_multiplier = 1.0
|
||||
if self._value_stream is not None:
|
||||
try:
|
||||
vs_multiplier = self._value_stream.get_value()
|
||||
except Exception:
|
||||
vs_multiplier = 1.0
|
||||
async def _send_entity_color(
|
||||
self, mapping: HALightMapping, r: int, g: int, b: int, vs_multiplier: float
|
||||
) -> None:
|
||||
"""Apply tolerance/threshold gates and push one entity update."""
|
||||
entity_id = mapping.entity_id
|
||||
# Cache for WS preview (always, even if HA call is skipped)
|
||||
self._latest_entity_colors[entity_id] = (r, g, b)
|
||||
|
||||
# Calculate brightness (0-255) from max channel
|
||||
brightness = max(r, g, b)
|
||||
|
||||
bs = (
|
||||
mapping.brightness_scale.value
|
||||
if hasattr(mapping.brightness_scale, "value")
|
||||
else mapping.brightness_scale
|
||||
)
|
||||
eff_scale = bs * vs_multiplier
|
||||
if eff_scale < 1.0:
|
||||
brightness = int(brightness * eff_scale)
|
||||
|
||||
should_be_on = (
|
||||
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
|
||||
)
|
||||
|
||||
prev_color = self._previous_colors.get(entity_id)
|
||||
was_on = self._previous_on.get(entity_id, True)
|
||||
|
||||
if should_be_on:
|
||||
new_color = (r, g, b)
|
||||
if prev_color is not None and was_on:
|
||||
dr = abs(r - prev_color[0])
|
||||
dg = abs(g - prev_color[1])
|
||||
db = abs(b - prev_color[2])
|
||||
if max(dr, dg, db) < self._color_tolerance:
|
||||
return # skip — colour hasn't changed enough
|
||||
|
||||
service_data = {
|
||||
"rgb_color": [r, g, b],
|
||||
"brightness": min(255, int(brightness * bs)),
|
||||
}
|
||||
transition_val = self._transition.value
|
||||
if transition_val > 0:
|
||||
service_data["transition"] = transition_val
|
||||
|
||||
await self._ha_runtime.call_service(
|
||||
domain="light",
|
||||
service="turn_on",
|
||||
service_data=service_data,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
self._previous_colors[entity_id] = new_color
|
||||
self._previous_on[entity_id] = True
|
||||
|
||||
elif was_on:
|
||||
await self._ha_runtime.call_service(
|
||||
domain="light",
|
||||
service="turn_off",
|
||||
service_data={},
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
self._previous_on[entity_id] = False
|
||||
self._previous_colors.pop(entity_id, None)
|
||||
|
||||
async def _update_lights(self, colors: np.ndarray) -> None:
|
||||
"""CSS mode: average each mapping's LED segment and dispatch."""
|
||||
led_count = len(colors)
|
||||
vs_multiplier = self._read_brightness_multiplier()
|
||||
|
||||
for mapping in self._light_mappings:
|
||||
if not mapping.entity_id:
|
||||
continue
|
||||
|
||||
# Resolve LED range
|
||||
start = max(0, mapping.led_start)
|
||||
end = mapping.led_end if mapping.led_end >= 0 else led_count
|
||||
end = min(end, led_count)
|
||||
if start >= end:
|
||||
continue
|
||||
|
||||
# Average the LED segment
|
||||
segment = colors[start:end]
|
||||
avg = segment.mean(axis=0).astype(int)
|
||||
r, g, b = int(avg[0]), int(avg[1]), int(avg[2])
|
||||
|
||||
# Cache for WS preview (always, even if HA call is skipped)
|
||||
self._latest_entity_colors[mapping.entity_id] = (r, g, b)
|
||||
|
||||
# Calculate brightness (0-255) from max channel
|
||||
brightness = max(r, g, b)
|
||||
|
||||
# Apply brightness scale and value source multiplier
|
||||
bs = (
|
||||
mapping.brightness_scale.value
|
||||
if hasattr(mapping.brightness_scale, "value")
|
||||
else mapping.brightness_scale
|
||||
)
|
||||
eff_scale = bs * vs_multiplier
|
||||
if eff_scale < 1.0:
|
||||
brightness = int(brightness * eff_scale)
|
||||
|
||||
# Check brightness threshold
|
||||
should_be_on = (
|
||||
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
|
||||
await self._send_entity_color(
|
||||
mapping, int(avg[0]), int(avg[1]), int(avg[2]), vs_multiplier
|
||||
)
|
||||
|
||||
entity_id = mapping.entity_id
|
||||
prev_color = self._previous_colors.get(entity_id)
|
||||
was_on = self._previous_on.get(entity_id, True)
|
||||
if self._ws_clients and self._latest_entity_colors:
|
||||
await self._broadcast_entity_colors()
|
||||
|
||||
if should_be_on:
|
||||
# Check if color changed beyond tolerance
|
||||
new_color = (r, g, b)
|
||||
if prev_color is not None and was_on:
|
||||
dr = abs(r - prev_color[0])
|
||||
dg = abs(g - prev_color[1])
|
||||
db = abs(b - prev_color[2])
|
||||
if max(dr, dg, db) < self._color_tolerance:
|
||||
continue # skip — color hasn't changed enough
|
||||
async def _update_lights_single_color(self, r: int, g: int, b: int) -> None:
|
||||
"""color_vs mode: push the same RGB triple to every mapping."""
|
||||
vs_multiplier = self._read_brightness_multiplier()
|
||||
|
||||
# Call light.turn_on
|
||||
service_data = {
|
||||
"rgb_color": [r, g, b],
|
||||
"brightness": min(255, int(brightness * bs)),
|
||||
}
|
||||
transition_val = self._transition.value
|
||||
if transition_val > 0:
|
||||
service_data["transition"] = transition_val
|
||||
for mapping in self._light_mappings:
|
||||
if not mapping.entity_id:
|
||||
continue
|
||||
await self._send_entity_color(mapping, r, g, b, vs_multiplier)
|
||||
|
||||
await self._ha_runtime.call_service(
|
||||
domain="light",
|
||||
service="turn_on",
|
||||
service_data=service_data,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
self._previous_colors[entity_id] = new_color
|
||||
self._previous_on[entity_id] = True
|
||||
|
||||
elif was_on:
|
||||
# Brightness dropped below threshold — turn off
|
||||
await self._ha_runtime.call_service(
|
||||
domain="light",
|
||||
service="turn_off",
|
||||
service_data={},
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
self._previous_on[entity_id] = False
|
||||
self._previous_colors.pop(entity_id, None)
|
||||
|
||||
# Broadcast colors to WS clients
|
||||
if self._ws_clients and self._latest_entity_colors:
|
||||
await self._broadcast_entity_colors()
|
||||
|
||||
@@ -377,3 +506,103 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self._ws_clients.remove(ws)
|
||||
|
||||
# ── Stop-action finalization ──
|
||||
|
||||
def _snapshot_mapped_entity_states(self) -> Dict[str, Any]:
|
||||
"""Capture current state of every mapped entity from the HA cache."""
|
||||
if not self._ha_runtime:
|
||||
return {}
|
||||
snap: Dict[str, Any] = {}
|
||||
for mapping in self._light_mappings:
|
||||
eid = mapping.entity_id
|
||||
if not eid:
|
||||
continue
|
||||
state = self._ha_runtime.get_state(eid)
|
||||
if state is not None:
|
||||
snap[eid] = state
|
||||
return snap
|
||||
|
||||
async def _apply_stop_action(self) -> None:
|
||||
"""Run the configured finalization on stop."""
|
||||
if self._stop_action == "none":
|
||||
return
|
||||
if not self._ha_runtime or not self._ha_runtime.is_connected:
|
||||
logger.info(
|
||||
f"HA light {self._target_id}: skipping stop_action "
|
||||
f"'{self._stop_action}' — HA not connected"
|
||||
)
|
||||
return
|
||||
|
||||
# Unique entity ids (a target may map the same entity twice in theory)
|
||||
entity_ids = []
|
||||
seen = set()
|
||||
for mapping in self._light_mappings:
|
||||
eid = mapping.entity_id
|
||||
if eid and eid not in seen:
|
||||
seen.add(eid)
|
||||
entity_ids.append(eid)
|
||||
|
||||
if not entity_ids:
|
||||
return
|
||||
|
||||
if self._stop_action == "turn_off":
|
||||
for eid in entity_ids:
|
||||
await self._ha_runtime.call_service(
|
||||
domain="light",
|
||||
service="turn_off",
|
||||
service_data={},
|
||||
target={"entity_id": eid},
|
||||
)
|
||||
return
|
||||
|
||||
if self._stop_action == "restore":
|
||||
for eid in entity_ids:
|
||||
state = self._captured_states.get(eid)
|
||||
if state is None:
|
||||
continue
|
||||
await self._restore_entity(eid, state)
|
||||
|
||||
async def _restore_entity(self, entity_id: str, state: Any) -> None:
|
||||
"""Restore one light entity to a captured HAEntityState."""
|
||||
if state.state == "off":
|
||||
await self._ha_runtime.call_service(
|
||||
domain="light",
|
||||
service="turn_off",
|
||||
service_data={},
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
return
|
||||
|
||||
if state.state != "on":
|
||||
# unknown / unavailable — best effort: do nothing
|
||||
return
|
||||
|
||||
attrs = state.attributes or {}
|
||||
service_data: Dict[str, Any] = {}
|
||||
|
||||
# Color: prefer rgb_color, then hs_color, then color_temp, then nothing
|
||||
rgb = attrs.get("rgb_color")
|
||||
if isinstance(rgb, (list, tuple)) and len(rgb) >= 3:
|
||||
service_data["rgb_color"] = [int(rgb[0]), int(rgb[1]), int(rgb[2])]
|
||||
else:
|
||||
hs = attrs.get("hs_color")
|
||||
color_temp = attrs.get("color_temp")
|
||||
color_temp_kelvin = attrs.get("color_temp_kelvin")
|
||||
if isinstance(hs, (list, tuple)) and len(hs) >= 2:
|
||||
service_data["hs_color"] = [float(hs[0]), float(hs[1])]
|
||||
elif color_temp_kelvin is not None:
|
||||
service_data["color_temp_kelvin"] = int(color_temp_kelvin)
|
||||
elif color_temp is not None:
|
||||
service_data["color_temp"] = int(color_temp)
|
||||
|
||||
brightness = attrs.get("brightness")
|
||||
if brightness is not None:
|
||||
service_data["brightness"] = int(brightness)
|
||||
|
||||
await self._ha_runtime.call_service(
|
||||
domain="light",
|
||||
service="turn_on",
|
||||
service_data=service_data,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
|
||||
@@ -75,6 +75,16 @@ class MetricsHistory:
|
||||
self._system: deque = deque(maxlen=MAX_SAMPLES)
|
||||
self._targets: Dict[str, deque] = {}
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
# Baselines for converting cumulative `errors_count` /
|
||||
# `frames_skipped` into per-second rates inside the system ring
|
||||
# buffer. None until the first sample arrives so we don't
|
||||
# synthesize a fake initial spike from "0 → live count".
|
||||
self._prev_total_errors: Optional[int] = None
|
||||
self._prev_total_skipped: Optional[int] = None
|
||||
# Same shape, but for the network throughput counter. Reset to
|
||||
# None when the cumulative sum drops (target stopped, counter
|
||||
# reset) so we never emit a negative rate.
|
||||
self._prev_total_bytes_sent: Optional[int] = None
|
||||
|
||||
async def start(self):
|
||||
"""Start the background sampling loop."""
|
||||
@@ -110,7 +120,6 @@ class MetricsHistory:
|
||||
"""Collect one snapshot of system and target metrics."""
|
||||
# System metrics (blocking psutil/nvml calls in thread pool)
|
||||
sys_snap = await asyncio.to_thread(_collect_system_snapshot)
|
||||
self._system.append(sys_snap)
|
||||
|
||||
# Per-target metrics from processor states
|
||||
try:
|
||||
@@ -121,22 +130,151 @@ class MetricsHistory:
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
active_ids = set()
|
||||
|
||||
# Aggregates across running targets — mirrors the dashboard's
|
||||
# frontend computation so the FPS / Capture FPS / Errors cells
|
||||
# can seed their sparklines from this ring buffer and survive
|
||||
# a page reload, the same way CPU / RAM already do.
|
||||
total_fps = 0.0
|
||||
total_capture_fps = 0.0
|
||||
total_capture_fps_actual = 0.0
|
||||
capture_actual_count = 0
|
||||
total_fps_target = 0.0
|
||||
total_errors_count = 0
|
||||
total_frames_skipped = 0
|
||||
running_count = 0
|
||||
# Network / send-timing aggregates across running targets.
|
||||
# `send_timing_*` reads "is the LED transport keeping up?" — a
|
||||
# leading indicator of network congestion that fires before
|
||||
# frames actually start dropping.
|
||||
total_bytes_sent = 0
|
||||
send_timing_max_ms = 0.0
|
||||
send_timing_sum_ms = 0.0
|
||||
send_timing_count = 0
|
||||
|
||||
for target_id, state in all_states.items():
|
||||
active_ids.add(target_id)
|
||||
if target_id not in self._targets:
|
||||
self._targets[target_id] = deque(maxlen=MAX_SAMPLES)
|
||||
if state.get("processing"):
|
||||
running_count += 1
|
||||
fps_actual = state.get("fps_actual")
|
||||
if isinstance(fps_actual, (int, float)) and fps_actual > 0:
|
||||
total_fps += float(fps_actual)
|
||||
fps_capture = state.get("fps_capture")
|
||||
if isinstance(fps_capture, (int, float)) and fps_capture > 0:
|
||||
total_capture_fps += float(fps_capture)
|
||||
fps_capture_actual = state.get("fps_capture_actual")
|
||||
# `None` means the stream type doesn't measure capture
|
||||
# (synthetic streams). Counted separately so the cell
|
||||
# can read "0 of 0" vs "0 of N stalled".
|
||||
if isinstance(fps_capture_actual, (int, float)):
|
||||
total_capture_fps_actual += float(fps_capture_actual)
|
||||
capture_actual_count += 1
|
||||
fps_target = state.get("fps_target")
|
||||
if isinstance(fps_target, (int, float)) and fps_target > 0:
|
||||
total_fps_target += float(fps_target)
|
||||
errors_count = state.get("errors_count")
|
||||
if isinstance(errors_count, (int, float)) and errors_count > 0:
|
||||
total_errors_count += int(errors_count)
|
||||
frames_skipped = state.get("frames_skipped")
|
||||
if isinstance(frames_skipped, (int, float)) and frames_skipped > 0:
|
||||
total_frames_skipped += int(frames_skipped)
|
||||
bytes_sent = state.get("bytes_sent")
|
||||
if isinstance(bytes_sent, (int, float)) and bytes_sent > 0:
|
||||
total_bytes_sent += int(bytes_sent)
|
||||
send_timing = state.get("timing_send_ms")
|
||||
if isinstance(send_timing, (int, float)) and send_timing >= 0:
|
||||
send_timing_sum_ms += float(send_timing)
|
||||
send_timing_count += 1
|
||||
if send_timing > send_timing_max_ms:
|
||||
send_timing_max_ms = float(send_timing)
|
||||
|
||||
self._targets[target_id].append(
|
||||
{
|
||||
"t": now,
|
||||
"fps": state.get("fps_actual"),
|
||||
"fps": fps_actual,
|
||||
"fps_current": state.get("fps_current"),
|
||||
"fps_target": state.get("fps_target"),
|
||||
"fps_target": fps_target,
|
||||
"timing": state.get("timing_total_ms"),
|
||||
"errors": state.get("errors_count", 0),
|
||||
}
|
||||
)
|
||||
|
||||
# Convert the cumulative error/skipped totals into per-second
|
||||
# rates. Guard against the first sample (no previous baseline)
|
||||
# and against counter resets when a target stops or restarts
|
||||
# (delta < 0 → treat as 0).
|
||||
errors_per_sec = 0.0
|
||||
skipped_per_sec = 0.0
|
||||
bytes_per_sec = 0.0
|
||||
if self._prev_total_errors is not None:
|
||||
delta = max(0, total_errors_count - self._prev_total_errors)
|
||||
errors_per_sec = delta / SAMPLE_INTERVAL
|
||||
if self._prev_total_skipped is not None:
|
||||
delta = max(0, total_frames_skipped - self._prev_total_skipped)
|
||||
skipped_per_sec = delta / SAMPLE_INTERVAL
|
||||
if self._prev_total_bytes_sent is not None:
|
||||
delta_b = max(0, total_bytes_sent - self._prev_total_bytes_sent)
|
||||
bytes_per_sec = delta_b / SAMPLE_INTERVAL
|
||||
self._prev_total_errors = total_errors_count
|
||||
self._prev_total_skipped = total_frames_skipped
|
||||
self._prev_total_bytes_sent = total_bytes_sent
|
||||
|
||||
# Device latency aggregates — pulled from the manager's
|
||||
# device-health view rather than re-deriving from per-target
|
||||
# state, so devices that are shared by multiple targets only
|
||||
# count once.
|
||||
device_latency_avg_ms: Optional[float] = None
|
||||
device_latency_max_ms: Optional[float] = None
|
||||
device_online_count = 0
|
||||
device_total_count = 0
|
||||
try:
|
||||
health_dicts = self._manager.get_all_device_health_dicts()
|
||||
except Exception as e:
|
||||
logger.error("Failed to get device health: %s", e)
|
||||
health_dicts = {}
|
||||
latency_sum = 0.0
|
||||
latency_n = 0
|
||||
latency_max = 0.0
|
||||
for _did, h in health_dicts.items():
|
||||
device_total_count += 1
|
||||
if h.get("device_online"):
|
||||
device_online_count += 1
|
||||
lat = h.get("device_latency_ms")
|
||||
if isinstance(lat, (int, float)) and lat >= 0:
|
||||
latency_sum += float(lat)
|
||||
latency_n += 1
|
||||
if lat > latency_max:
|
||||
latency_max = float(lat)
|
||||
if latency_n > 0:
|
||||
device_latency_avg_ms = round(latency_sum / latency_n, 1)
|
||||
device_latency_max_ms = round(latency_max, 1)
|
||||
|
||||
sys_snap["total_fps"] = round(total_fps, 1)
|
||||
sys_snap["total_capture_fps"] = round(total_capture_fps, 1)
|
||||
sys_snap["total_capture_fps_actual"] = round(total_capture_fps_actual, 1)
|
||||
sys_snap["capture_actual_count"] = capture_actual_count
|
||||
sys_snap["total_fps_target"] = round(total_fps_target, 1)
|
||||
sys_snap["total_errors_count"] = total_errors_count
|
||||
sys_snap["total_frames_skipped"] = total_frames_skipped
|
||||
sys_snap["errors_per_sec"] = round(errors_per_sec, 3)
|
||||
sys_snap["skipped_per_sec"] = round(skipped_per_sec, 3)
|
||||
sys_snap["running_count"] = running_count
|
||||
sys_snap["total_bytes_sent"] = total_bytes_sent
|
||||
sys_snap["bytes_per_sec"] = round(bytes_per_sec, 1)
|
||||
sys_snap["send_timing_avg_ms"] = (
|
||||
round(send_timing_sum_ms / send_timing_count, 2) if send_timing_count > 0 else 0.0
|
||||
)
|
||||
sys_snap["send_timing_max_ms"] = round(send_timing_max_ms, 2)
|
||||
sys_snap["send_timing_count"] = send_timing_count
|
||||
sys_snap["device_latency_avg_ms"] = device_latency_avg_ms
|
||||
sys_snap["device_latency_max_ms"] = device_latency_max_ms
|
||||
sys_snap["device_online_count"] = device_online_count
|
||||
sys_snap["device_total_count"] = device_total_count
|
||||
|
||||
self._system.append(sys_snap)
|
||||
|
||||
# Prune deques for targets no longer registered
|
||||
for tid in list(self._targets.keys()):
|
||||
if tid not in active_ids:
|
||||
|
||||
@@ -167,6 +167,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
gradient_store=deps.gradient_store,
|
||||
event_bus=deps.game_event_bus,
|
||||
audio_processing_template_store=deps.audio_processing_template_store,
|
||||
sync_clock_manager=deps.sync_clock_manager,
|
||||
)
|
||||
if deps.value_source_store
|
||||
else None
|
||||
@@ -427,7 +428,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
self,
|
||||
target_id: str,
|
||||
ha_source_id: str,
|
||||
source_kind: str = "css",
|
||||
color_strip_source_id: str = "",
|
||||
color_value_source_id: str = "",
|
||||
brightness=None,
|
||||
# legacy compat
|
||||
brightness_value_source_id: str = "",
|
||||
@@ -436,6 +439,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
transition=None,
|
||||
min_brightness_threshold: int = 0,
|
||||
color_tolerance: int = 5,
|
||||
stop_action: str = "none",
|
||||
) -> None:
|
||||
"""Register a Home Assistant light target processor."""
|
||||
if target_id in self._processors:
|
||||
@@ -446,13 +450,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
proc = HALightTargetProcessor(
|
||||
target_id=target_id,
|
||||
ha_source_id=ha_source_id,
|
||||
source_kind=source_kind,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
color_value_source_id=color_value_source_id,
|
||||
brightness=brightness,
|
||||
light_mappings=light_mappings or [],
|
||||
update_rate=update_rate,
|
||||
transition=transition,
|
||||
min_brightness_threshold=min_brightness_threshold,
|
||||
color_tolerance=color_tolerance,
|
||||
stop_action=stop_action,
|
||||
ctx=self._build_context(),
|
||||
)
|
||||
self._processors[target_id] = proc
|
||||
@@ -770,8 +777,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
|
||||
# ===== LIFECYCLE =====
|
||||
|
||||
async def stop_all(self):
|
||||
"""Stop processing and health monitoring for all targets and devices."""
|
||||
async def stop_all(self, restore_devices: bool = True):
|
||||
"""Stop processing and health monitoring for all targets and devices.
|
||||
|
||||
When ``restore_devices`` is False, processor tasks are cancelled
|
||||
directly instead of going through ``proc.stop()`` (which sends
|
||||
per-device auto_shutdown restore frames), and the global
|
||||
idle-state restore loop is skipped. Used by the "Nothing"
|
||||
shutdown action so lights freeze on their last frame regardless
|
||||
of per-device auto_shutdown.
|
||||
"""
|
||||
await self._metrics_history.stop()
|
||||
await self.stop_health_monitoring()
|
||||
|
||||
@@ -781,18 +796,35 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
if rs.restart_task and not rs.restart_task.done():
|
||||
rs.restart_task.cancel()
|
||||
|
||||
# Stop all processors
|
||||
for target_id, proc in list(self._processors.items()):
|
||||
if proc.is_running:
|
||||
try:
|
||||
await proc.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping target {target_id}: {e}")
|
||||
if restore_devices:
|
||||
# Stop all processors (per-device auto_shutdown decides whether
|
||||
# the prior device state is restored).
|
||||
for target_id, proc in list(self._processors.items()):
|
||||
if proc.is_running:
|
||||
try:
|
||||
await proc.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping target {target_id}: {e}")
|
||||
|
||||
# Restore idle state for devices that have auto-restore enabled
|
||||
# (serial devices already dark from processor close; WLED restored by snapshot)
|
||||
for device_id in self._devices:
|
||||
await self._restore_device_idle_state(device_id)
|
||||
# Restore idle state for devices that have auto-restore enabled
|
||||
# (serial devices already dark from processor close; WLED restored by snapshot)
|
||||
for device_id in self._devices:
|
||||
await self._restore_device_idle_state(device_id)
|
||||
else:
|
||||
# "Nothing" mode: cancel processor capture tasks without sending
|
||||
# restore frames so the LEDs keep displaying the last frame.
|
||||
# ``cancel_task`` (defined on ``TargetProcessor``) awaits the
|
||||
# cancellation so the loop's current iteration completes — no
|
||||
# half-written frame on the wire when the process exits.
|
||||
for target_id, proc in list(self._processors.items()):
|
||||
try:
|
||||
await proc.cancel_task()
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling task for target {target_id}: {e}")
|
||||
logger.info(
|
||||
"Shutdown action 'nothing': skipped device restore for %d target(s)",
|
||||
len(self._processors),
|
||||
)
|
||||
|
||||
# Close any cached idle LED clients (WLED only; serial has no cached clients)
|
||||
for did in list(self._idle_clients):
|
||||
|
||||
@@ -16,6 +16,10 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
|
||||
@@ -65,6 +69,13 @@ class ProcessingMetrics:
|
||||
# Streaming liveness (HTTP probe during DDP)
|
||||
device_streaming_reachable: Optional[bool] = None
|
||||
fps_effective: int = 0
|
||||
# Cumulative LED-payload bytes sent to the device. Aggregated across
|
||||
# all running targets in MetricsHistory to derive a per-second
|
||||
# network throughput sparkline. Counts the color-array payload only;
|
||||
# protocol overhead (DDP/UDP/IP headers) is sub-5 % for any
|
||||
# non-trivial LED count and is intentionally ignored to keep the
|
||||
# counter cheap (`np.ndarray.nbytes`, no per-frame allocation).
|
||||
bytes_sent: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -145,6 +156,32 @@ class TargetProcessor(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
async def cancel_task(self) -> None:
|
||||
"""Cancel the processing task without restoring device state.
|
||||
|
||||
Used by ``ProcessorManager.stop_all(restore_devices=False)`` at
|
||||
server shutdown when the user has chosen "Nothing" — LEDs should
|
||||
keep displaying their last frame, so we skip the per-device
|
||||
``stop()`` path that sends restore frames. We still flip
|
||||
``_is_running`` and await the cancellation so the loop's current
|
||||
iteration completes (no half-written frame on the wire).
|
||||
|
||||
Subclasses with extra non-device cleanup (e.g. live-stream
|
||||
release) may override this; the default just stops the task.
|
||||
"""
|
||||
self._is_running = False
|
||||
task = self._task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
# Log but don't propagate — caller is shutting down.
|
||||
logger.debug("Task raised during cancel_task", exc_info=True)
|
||||
self._task = None
|
||||
|
||||
# ----- Settings -----
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
|
||||
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
||||
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
|
||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||
from ledgrab.storage.audio_source_store import AudioSourceStore
|
||||
from ledgrab.storage.value_source import ValueSource
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
@@ -599,31 +600,61 @@ class DaylightValueStream(ValueStream):
|
||||
speed: float = 1.0,
|
||||
use_real_time: bool = False,
|
||||
latitude: float = 50.0,
|
||||
longitude: float = 0.0,
|
||||
min_value: float = 0.0,
|
||||
max_value: float = 1.0,
|
||||
):
|
||||
from ledgrab.core.processing.daylight_stream import _get_daylight_lut
|
||||
|
||||
self._lut = _get_daylight_lut()
|
||||
self._default_lut = _get_daylight_lut()
|
||||
self._speed = speed
|
||||
self._use_real_time = use_real_time
|
||||
self._latitude = latitude
|
||||
self._longitude = longitude
|
||||
self._min = min_value
|
||||
self._max = max_value
|
||||
self._start_time = time.perf_counter()
|
||||
# Cache: (sr_min, ss_min) → LUT, mirroring DaylightColorStripStream
|
||||
self._lut_cache: Dict[Tuple[int, int], np.ndarray] = {}
|
||||
|
||||
def _resolve_lut(self, day_of_year: Optional[int], utc_offset_hours: float) -> np.ndarray:
|
||||
if day_of_year is None:
|
||||
return self._default_lut
|
||||
from ledgrab.core.processing.daylight_stream import (
|
||||
_build_lut_for_solar_times,
|
||||
_compute_solar_times,
|
||||
)
|
||||
|
||||
sr, ss = _compute_solar_times(
|
||||
self._latitude, self._longitude, day_of_year, utc_offset_hours
|
||||
)
|
||||
key = (int(round(sr * 60)), int(round(ss * 60)))
|
||||
lut = self._lut_cache.get(key)
|
||||
if lut is None:
|
||||
lut = _build_lut_for_solar_times(sr, ss)
|
||||
if len(self._lut_cache) > 8:
|
||||
self._lut_cache.clear()
|
||||
self._lut_cache[key] = lut
|
||||
return lut
|
||||
|
||||
def get_value(self) -> float:
|
||||
from ledgrab.core.processing.daylight_settings import get_daylight_timezone
|
||||
from ledgrab.core.processing.daylight_stream import _now_in_tz, _utc_offset_hours_for
|
||||
|
||||
tz_name = get_daylight_timezone()
|
||||
if self._use_real_time:
|
||||
now = datetime.now()
|
||||
now = _now_in_tz(tz_name)
|
||||
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
|
||||
lut = self._resolve_lut(now.timetuple().tm_yday, _utc_offset_hours_for(tz_name, now))
|
||||
else:
|
||||
t_elapsed = time.perf_counter() - self._start_time
|
||||
cycle_seconds = 240.0 / max(self._speed, 0.01)
|
||||
phase = (t_elapsed % cycle_seconds) / cycle_seconds
|
||||
minute_of_day = phase * 1440.0
|
||||
lut = self._default_lut
|
||||
|
||||
idx = int(minute_of_day) % 1440
|
||||
r, g, b = self._lut[idx]
|
||||
r, g, b = lut[idx]
|
||||
|
||||
# BT.601 luminance → 0..1
|
||||
luminance = (0.299 * float(r) + 0.587 * float(g) + 0.114 * float(b)) / 255.0
|
||||
@@ -637,8 +668,10 @@ class DaylightValueStream(ValueStream):
|
||||
self._speed = source.speed
|
||||
self._use_real_time = source.use_real_time
|
||||
self._latitude = source.latitude
|
||||
self._longitude = source.longitude
|
||||
self._min = source.min_value
|
||||
self._max = source.max_value
|
||||
self._lut_cache.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -669,10 +702,34 @@ class StaticColorValueStream(ValueStream):
|
||||
)
|
||||
|
||||
|
||||
class AnimatedColorValueStream(ValueStream):
|
||||
"""Cycles through a list of colors over time."""
|
||||
def _ease_color_frac(t: float, easing: str) -> float:
|
||||
"""Remap a 0..1 segment fraction through a named easing curve.
|
||||
|
||||
def __init__(self, colors, speed=10.0, easing="linear"):
|
||||
Unknown names fall back to linear so older configs and forward-compat
|
||||
payloads keep working.
|
||||
"""
|
||||
if easing == "ease_in":
|
||||
return t * t * t
|
||||
if easing == "ease_out":
|
||||
u = 1.0 - t
|
||||
return 1.0 - u * u * u
|
||||
if easing == "ease_in_out":
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
if easing == "sine":
|
||||
return 0.5 - 0.5 * math.cos(math.pi * t)
|
||||
return t
|
||||
|
||||
|
||||
class AnimatedColorValueStream(ValueStream):
|
||||
"""Cycles through a list of colors over time.
|
||||
|
||||
When a ``clock`` runtime is provided, animation is driven by the
|
||||
clock's pause-aware elapsed time and speed multiplier so multiple
|
||||
streams sharing the same clock stay in lockstep. When no clock is
|
||||
set, falls back to wall-clock time scaled by ``speed`` (cycles/min).
|
||||
"""
|
||||
|
||||
def __init__(self, colors, speed=10.0, easing="linear", clock=None):
|
||||
self._colors = [
|
||||
(int(c[0]), int(c[1]), int(c[2]))
|
||||
for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
|
||||
@@ -681,24 +738,47 @@ class AnimatedColorValueStream(ValueStream):
|
||||
self._speed = max(0.01, float(speed))
|
||||
self._easing = easing
|
||||
self._start_time = 0.0
|
||||
self._clock = clock
|
||||
# Last frame state — held while the clock is paused so get_color()
|
||||
# returns a stable color instead of jumping.
|
||||
self._last_phase = 0.0
|
||||
|
||||
def start(self) -> None:
|
||||
self._start_time = time.monotonic()
|
||||
|
||||
def set_clock(self, clock) -> None:
|
||||
"""Set or clear the sync clock runtime. Thread-safe (atomic ref swap)."""
|
||||
self._clock = clock
|
||||
|
||||
def get_value(self) -> float:
|
||||
r, g, b = self.get_color()
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
||||
|
||||
def get_color(self) -> tuple:
|
||||
elapsed = time.monotonic() - self._start_time
|
||||
cycle_time = 60.0 / self._speed
|
||||
clock = self._clock
|
||||
n = len(self._colors)
|
||||
if clock is not None:
|
||||
# Clock provides real elapsed seconds (pause-aware) and a speed
|
||||
# multiplier. We treat self._speed as the base cpm and apply the
|
||||
# clock's speed on top, matching the convention used by CSS
|
||||
# animation streams.
|
||||
cycle_time = 60.0 / max(0.01, self._speed * float(clock.speed))
|
||||
if not clock.is_running:
|
||||
phase = self._last_phase
|
||||
else:
|
||||
elapsed = clock.get_time()
|
||||
phase = (elapsed / cycle_time * n) % n
|
||||
self._last_phase = phase
|
||||
else:
|
||||
elapsed = time.monotonic() - self._start_time
|
||||
cycle_time = 60.0 / self._speed
|
||||
phase = (elapsed / cycle_time * n) % n
|
||||
self._last_phase = phase
|
||||
|
||||
if self._easing == "step":
|
||||
idx = int((elapsed / cycle_time * n) % n)
|
||||
return self._colors[idx]
|
||||
phase = (elapsed / cycle_time * n) % n
|
||||
return self._colors[int(phase) % n]
|
||||
idx = int(phase)
|
||||
frac = phase - idx
|
||||
frac = _ease_color_frac(phase - idx, self._easing)
|
||||
c1 = self._colors[idx % n]
|
||||
c2 = self._colors[(idx + 1) % n]
|
||||
return (
|
||||
@@ -1466,6 +1546,7 @@ class ValueStreamManager:
|
||||
gradient_store: Optional[Any] = None,
|
||||
event_bus: Optional["GameEventBus"] = None,
|
||||
audio_processing_template_store=None,
|
||||
sync_clock_manager: Optional["SyncClockManager"] = None,
|
||||
):
|
||||
self._value_source_store = value_source_store
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
@@ -1477,8 +1558,12 @@ class ValueStreamManager:
|
||||
self._gradient_store = gradient_store
|
||||
self._event_bus = event_bus
|
||||
self._audio_processing_template_store = audio_processing_template_store
|
||||
self._sync_clock_manager = sync_clock_manager
|
||||
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
||||
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
||||
# Tracks which clock_id (if any) was acquired for each stream so we
|
||||
# can release/swap it without re-querying the store at teardown time.
|
||||
self._stream_clock_ids: Dict[str, str] = {} # vs_id → clock_id
|
||||
|
||||
def acquire(self, vs_id: str) -> ValueStream:
|
||||
"""Get or create a shared ValueStream for the given ValueSource.
|
||||
@@ -1492,7 +1577,7 @@ class ValueStreamManager:
|
||||
return self._streams[vs_id]
|
||||
|
||||
source = self._value_source_store.get_source(vs_id)
|
||||
stream = self._create_stream(source)
|
||||
stream = self._create_stream(source, vs_id)
|
||||
stream.start()
|
||||
self._streams[vs_id] = stream
|
||||
self._ref_counts[vs_id] = 1
|
||||
@@ -1512,6 +1597,7 @@ class ValueStreamManager:
|
||||
if stream:
|
||||
stream.stop()
|
||||
del self._ref_counts[vs_id]
|
||||
self._release_clock_for(vs_id)
|
||||
logger.info(f"Released value stream {vs_id} (last ref)")
|
||||
else:
|
||||
logger.info(f"Released ref for value stream {vs_id} (refs={refs})")
|
||||
@@ -1527,8 +1613,53 @@ class ValueStreamManager:
|
||||
stream = self._streams.get(vs_id)
|
||||
if stream:
|
||||
stream.update_source(source)
|
||||
self._sync_clock_binding(vs_id, source, stream)
|
||||
logger.debug(f"Updated value stream {vs_id}")
|
||||
|
||||
def _sync_clock_binding(self, vs_id: str, source: "ValueSource", stream: ValueStream) -> None:
|
||||
"""Hot-swap the sync-clock runtime attached to *stream* if needed."""
|
||||
if not self._sync_clock_manager or not hasattr(stream, "set_clock"):
|
||||
return
|
||||
new_clock_id = getattr(source, "clock_id", None) or None
|
||||
old_clock_id = self._stream_clock_ids.get(vs_id)
|
||||
if new_clock_id == old_clock_id:
|
||||
return
|
||||
new_runtime = None
|
||||
if new_clock_id:
|
||||
try:
|
||||
new_runtime = self._sync_clock_manager.acquire(new_clock_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not acquire sync clock %s for value stream %s: %s",
|
||||
new_clock_id,
|
||||
vs_id,
|
||||
e,
|
||||
)
|
||||
new_runtime = None
|
||||
new_clock_id = None
|
||||
try:
|
||||
stream.set_clock(new_runtime)
|
||||
except Exception as e:
|
||||
logger.warning("set_clock failed on value stream %s: %s", vs_id, e)
|
||||
if new_clock_id:
|
||||
self._stream_clock_ids[vs_id] = new_clock_id
|
||||
else:
|
||||
self._stream_clock_ids.pop(vs_id, None)
|
||||
if old_clock_id:
|
||||
try:
|
||||
self._sync_clock_manager.release(old_clock_id)
|
||||
except Exception as e:
|
||||
logger.debug("Sync clock release for %s: %s", old_clock_id, e)
|
||||
|
||||
def _release_clock_for(self, vs_id: str) -> None:
|
||||
"""Release the sync clock acquired for *vs_id* (if any)."""
|
||||
clock_id = self._stream_clock_ids.pop(vs_id, None)
|
||||
if clock_id and self._sync_clock_manager:
|
||||
try:
|
||||
self._sync_clock_manager.release(clock_id)
|
||||
except Exception as e:
|
||||
logger.debug("Sync clock release for %s: %s", clock_id, e)
|
||||
|
||||
def refresh_audio_filter_pipelines(self, template_id: str) -> None:
|
||||
"""Rebuild audio filter pipelines for any running AudioValueStream
|
||||
that references the given audio processing template ID.
|
||||
@@ -1555,11 +1686,19 @@ class ValueStreamManager:
|
||||
stream.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping value stream {vs_id}: {e}")
|
||||
# Release any sync clocks held by streams.
|
||||
if self._sync_clock_manager:
|
||||
for vs_id, clock_id in self._stream_clock_ids.items():
|
||||
try:
|
||||
self._sync_clock_manager.release(clock_id)
|
||||
except Exception as e:
|
||||
logger.debug("Sync clock release for %s during shutdown: %s", clock_id, e)
|
||||
self._stream_clock_ids.clear()
|
||||
self._streams.clear()
|
||||
self._ref_counts.clear()
|
||||
logger.info("Released all value streams")
|
||||
|
||||
def _create_stream(self, source: "ValueSource") -> ValueStream:
|
||||
def _create_stream(self, source: "ValueSource", vs_id: Optional[str] = None) -> ValueStream:
|
||||
"""Factory: create the appropriate ValueStream for a ValueSource."""
|
||||
from ledgrab.storage.value_source import (
|
||||
AdaptiveValueSource,
|
||||
@@ -1608,6 +1747,7 @@ class ValueStreamManager:
|
||||
speed=source.speed,
|
||||
use_real_time=source.use_real_time,
|
||||
latitude=source.latitude,
|
||||
longitude=source.longitude,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
@@ -1634,10 +1774,24 @@ class ValueStreamManager:
|
||||
return StaticColorValueStream(color=source.color)
|
||||
|
||||
if isinstance(source, AnimatedColorValueSource):
|
||||
clock_runtime = None
|
||||
if source.clock_id and self._sync_clock_manager:
|
||||
try:
|
||||
clock_runtime = self._sync_clock_manager.acquire(source.clock_id)
|
||||
if vs_id is not None:
|
||||
self._stream_clock_ids[vs_id] = source.clock_id
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not acquire sync clock %s for value source %s: %s",
|
||||
source.clock_id,
|
||||
source.id,
|
||||
e,
|
||||
)
|
||||
return AnimatedColorValueStream(
|
||||
colors=source.colors,
|
||||
speed=source.speed,
|
||||
easing=source.easing,
|
||||
clock=clock_runtime,
|
||||
)
|
||||
|
||||
if isinstance(source, AdaptiveTimeColorValueSource):
|
||||
|
||||
@@ -82,10 +82,17 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._resolved_display_index: Optional[int] = None
|
||||
self._device_config = None # populated on start(), typed DeviceConfig
|
||||
|
||||
# Fit-to-device linspace cache (per-instance to avoid cross-target thrash)
|
||||
# Fit-to-device cache (per-instance to avoid cross-target thrash).
|
||||
# Holds precomputed floor/ceil source indices, fractional weights,
|
||||
# and reusable scratch buffers so the per-frame interpolation runs
|
||||
# entirely with in-place numpy ops — no allocations.
|
||||
self._fit_cache_key: tuple = (0, 0)
|
||||
self._fit_cache_src: Optional[np.ndarray] = None
|
||||
self._fit_cache_dst: Optional[np.ndarray] = None
|
||||
self._fit_floor_idx: Optional[np.ndarray] = None
|
||||
self._fit_ceil_idx: Optional[np.ndarray] = None
|
||||
self._fit_frac: Optional[np.ndarray] = None
|
||||
self._fit_left_u8: Optional[np.ndarray] = None
|
||||
self._fit_right_u8: Optional[np.ndarray] = None
|
||||
self._fit_blend_f32: Optional[np.ndarray] = None
|
||||
self._fit_result_buf: Optional[np.ndarray] = None
|
||||
|
||||
# LED preview WebSocket clients
|
||||
@@ -384,6 +391,69 @@ class WledTargetProcessor(TargetProcessor):
|
||||
logger.debug("Device probe failed for %s: %s", device_url, e)
|
||||
return False
|
||||
|
||||
async def _run_liveness_probe_loop(self, device_url: str, probe_interval: float = 10.0) -> None:
|
||||
"""Background loop that probes the device and updates adaptive state.
|
||||
|
||||
Runs independently from the per-frame processing loop so the hot
|
||||
path doesn't pay for `_probe_task.done()` / scheduling checks every
|
||||
iteration. Updates ``self._device_reachable``,
|
||||
``self._metrics.device_streaming_reachable`` and (when adaptive FPS
|
||||
is enabled) ``self._effective_fps`` directly.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(2.0)) as client:
|
||||
while self._is_running:
|
||||
try:
|
||||
reachable = await self._probe_device(device_url, client)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
reachable = False
|
||||
|
||||
prev_reachable = self._device_reachable
|
||||
self._device_reachable = reachable
|
||||
self._metrics.device_streaming_reachable = reachable
|
||||
|
||||
if self._adaptive_fps:
|
||||
target_fps = self._target_fps if self._target_fps > 0 else 30
|
||||
if not reachable:
|
||||
old_eff = self._effective_fps
|
||||
new_eff = max(1, self._effective_fps // 2)
|
||||
if old_eff != new_eff:
|
||||
self._effective_fps = new_eff
|
||||
logger.warning(
|
||||
"[ADAPTIVE] %s device unreachable, FPS %d → %d",
|
||||
self._target_id,
|
||||
old_eff,
|
||||
new_eff,
|
||||
)
|
||||
elif self._effective_fps < target_fps:
|
||||
step = max(1, target_fps // 8)
|
||||
old_eff = self._effective_fps
|
||||
new_eff = min(target_fps, self._effective_fps + step)
|
||||
if old_eff != new_eff:
|
||||
self._effective_fps = new_eff
|
||||
logger.info(
|
||||
"[ADAPTIVE] %s device reachable, FPS %d → %d",
|
||||
self._target_id,
|
||||
old_eff,
|
||||
new_eff,
|
||||
)
|
||||
|
||||
if prev_reachable != reachable:
|
||||
logger.info(
|
||||
"[PROBE] %s device %s",
|
||||
self._target_id,
|
||||
"reachable" if reachable else "UNREACHABLE",
|
||||
)
|
||||
|
||||
# Cooperative sleep that promptly notices stop().
|
||||
# Sleep in 0.5s chunks so cancellation latency stays < 0.5s.
|
||||
slept = 0.0
|
||||
while slept < probe_interval and self._is_running:
|
||||
chunk = min(0.5, probe_interval - slept)
|
||||
await asyncio.sleep(chunk)
|
||||
slept += chunk
|
||||
|
||||
def get_display_index(self) -> Optional[int]:
|
||||
"""Display index being captured, from the active stream."""
|
||||
if self._resolved_display_index is not None:
|
||||
@@ -399,8 +469,14 @@ class WledTargetProcessor(TargetProcessor):
|
||||
fps_target = self._target_fps
|
||||
|
||||
css_timing: dict = {}
|
||||
css_capture_fps: Optional[int] = None
|
||||
css_capture_fps_actual: Optional[float] = None
|
||||
if self._is_running and self._css_stream is not None:
|
||||
css_timing = self._css_stream.get_last_timing()
|
||||
css_capture_fps = getattr(self._css_stream, "target_fps", None)
|
||||
# `actual_fps` is None for synthetic streams (gradient/static/...)
|
||||
# — only picture/audio/api-input style streams measure it.
|
||||
css_capture_fps_actual = getattr(self._css_stream, "actual_fps", None)
|
||||
|
||||
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
|
||||
# Picture source timing
|
||||
@@ -444,6 +520,9 @@ class WledTargetProcessor(TargetProcessor):
|
||||
"fps_actual": metrics.fps_actual if self._is_running else None,
|
||||
"fps_potential": metrics.fps_potential if self._is_running else None,
|
||||
"fps_target": fps_target,
|
||||
"fps_capture": css_capture_fps,
|
||||
"fps_capture_actual": css_capture_fps_actual,
|
||||
"bytes_sent": metrics.bytes_sent if self._is_running else None,
|
||||
"frames_skipped": metrics.frames_skipped if self._is_running else None,
|
||||
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
|
||||
"fps_current": metrics.fps_current if self._is_running else None,
|
||||
@@ -637,24 +716,57 @@ class WledTargetProcessor(TargetProcessor):
|
||||
# ----- Private: processing loop -----
|
||||
|
||||
def _fit_to_device(self, colors: np.ndarray, device_led_count: int) -> np.ndarray:
|
||||
"""Resample colors to match the target LED count."""
|
||||
"""Resample colors to match the target LED count.
|
||||
|
||||
Linear interpolation using floor/ceil source indices and fractional
|
||||
weights — all precomputed when ``(n, device_led_count)`` changes.
|
||||
Per-frame work is two ``np.take`` calls and a few in-place ops on
|
||||
pre-allocated scratch buffers. No per-frame allocations.
|
||||
"""
|
||||
n = len(colors)
|
||||
if n == device_led_count or device_led_count <= 0:
|
||||
return colors
|
||||
|
||||
key = (n, device_led_count)
|
||||
if self._fit_cache_key != key:
|
||||
self._fit_cache_src = np.linspace(0, 1, n)
|
||||
self._fit_cache_dst = np.linspace(0, 1, device_led_count)
|
||||
self._fit_cache_key = key
|
||||
if device_led_count > 1 and n > 1:
|
||||
t = np.arange(device_led_count, dtype=np.float64) * (
|
||||
(n - 1) / (device_led_count - 1)
|
||||
)
|
||||
else:
|
||||
t = np.zeros(device_led_count, dtype=np.float64)
|
||||
floor_idx = np.floor(t).astype(np.int64)
|
||||
np.clip(floor_idx, 0, n - 1, out=floor_idx)
|
||||
ceil_idx = np.minimum(floor_idx + 1, n - 1)
|
||||
frac = (t - floor_idx).astype(np.float32)[:, None] # (M, 1) for channel broadcast
|
||||
self._fit_floor_idx = floor_idx
|
||||
self._fit_ceil_idx = ceil_idx
|
||||
self._fit_frac = frac
|
||||
self._fit_left_u8 = np.empty((device_led_count, 3), dtype=np.uint8)
|
||||
self._fit_right_u8 = np.empty((device_led_count, 3), dtype=np.uint8)
|
||||
self._fit_blend_f32 = np.empty((device_led_count, 3), dtype=np.float32)
|
||||
self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
|
||||
buf = self._fit_result_buf
|
||||
for ch in range(min(colors.shape[1], 3)):
|
||||
np.copyto(
|
||||
buf[:, ch],
|
||||
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]),
|
||||
casting="unsafe",
|
||||
)
|
||||
return buf
|
||||
self._fit_cache_key = key
|
||||
|
||||
# Source slice: ColorStripStreams produce (N, 3); guard against (N, 4) RGBA.
|
||||
rgb = colors[:, :3] if colors.ndim == 2 and colors.shape[1] > 3 else colors
|
||||
|
||||
left_u8 = self._fit_left_u8
|
||||
right_u8 = self._fit_right_u8
|
||||
blend = self._fit_blend_f32
|
||||
out = self._fit_result_buf
|
||||
|
||||
# uint8 → uint8 take with `out=` — no allocation
|
||||
np.take(rgb, self._fit_floor_idx, axis=0, out=left_u8)
|
||||
np.take(rgb, self._fit_ceil_idx, axis=0, out=right_u8)
|
||||
# Promote right to float32 in pre-allocated scratch
|
||||
np.copyto(blend, right_u8, casting="unsafe") # blend = right (float32)
|
||||
blend -= left_u8 # blend = right - left
|
||||
blend *= self._fit_frac # blend = frac * (right - left)
|
||||
blend += left_u8 # blend = left + frac * (right - left)
|
||||
np.clip(blend, 0, 255, out=blend)
|
||||
np.copyto(out, blend, casting="unsafe") # float32 → uint8
|
||||
return out
|
||||
|
||||
async def _send_to_device(self, send_colors: np.ndarray) -> float:
|
||||
"""Send colors to LED device and return send time in ms."""
|
||||
@@ -663,6 +775,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._led_client.send_pixels_fast(send_colors)
|
||||
else:
|
||||
await self._led_client.send_pixels(send_colors)
|
||||
# Approximate network throughput counter (LED-payload bytes only).
|
||||
self._metrics.bytes_sent += int(send_colors.nbytes)
|
||||
return (time.perf_counter() - t_start) * 1000
|
||||
|
||||
@staticmethod
|
||||
@@ -774,14 +888,16 @@ class WledTargetProcessor(TargetProcessor):
|
||||
_diag_slow_iters: collections.deque = collections.deque(maxlen=50)
|
||||
_diag_iter_times: collections.deque = collections.deque(maxlen=300)
|
||||
# --- Liveness probe + adaptive FPS ---
|
||||
# The probe runs as an independent task so the hot loop doesn't
|
||||
# pay for per-iteration probe-state checks.
|
||||
_device_url = self._device_config.device_url if self._device_config else ""
|
||||
_probe_enabled = _device_url.startswith("http")
|
||||
_probe_interval = 10.0 # seconds between probes
|
||||
_last_probe_time = 0.0 # force first probe soon (after 10s)
|
||||
_probe_task: Optional[asyncio.Task] = None
|
||||
_probe_client: Optional[httpx.AsyncClient] = None
|
||||
if _probe_enabled:
|
||||
_probe_client = httpx.AsyncClient(timeout=httpx.Timeout(2.0))
|
||||
_probe_task = asyncio.create_task(
|
||||
self._run_liveness_probe_loop(_device_url),
|
||||
name=f"liveness-probe-{self._target_id}",
|
||||
)
|
||||
self._effective_fps = self._target_fps
|
||||
self._device_reachable = None
|
||||
|
||||
@@ -805,63 +921,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
loop_start = now = time.perf_counter()
|
||||
target_fps = self._target_fps if self._target_fps > 0 else 30
|
||||
|
||||
# --- Liveness probe ---
|
||||
# Collect result as soon as it's done (every iteration)
|
||||
if _probe_task is not None and _probe_task.done():
|
||||
try:
|
||||
reachable = _probe_task.result()
|
||||
except Exception:
|
||||
reachable = False
|
||||
prev_reachable = self._device_reachable
|
||||
self._device_reachable = reachable
|
||||
self._metrics.device_streaming_reachable = reachable
|
||||
_probe_task = None
|
||||
|
||||
if self._adaptive_fps:
|
||||
if not reachable:
|
||||
# Backoff: halve effective FPS
|
||||
old_eff = self._effective_fps
|
||||
self._effective_fps = max(1, self._effective_fps // 2)
|
||||
if old_eff != self._effective_fps:
|
||||
logger.warning(
|
||||
f"[ADAPTIVE] {self._target_id} device unreachable, "
|
||||
f"FPS {old_eff} → {self._effective_fps}"
|
||||
)
|
||||
next_frame_time = time.perf_counter()
|
||||
else:
|
||||
# Recovery: gradually increase
|
||||
if self._effective_fps < target_fps:
|
||||
step = max(1, target_fps // 8)
|
||||
old_eff = self._effective_fps
|
||||
self._effective_fps = min(
|
||||
target_fps, self._effective_fps + step
|
||||
)
|
||||
if old_eff != self._effective_fps:
|
||||
logger.info(
|
||||
f"[ADAPTIVE] {self._target_id} device reachable, "
|
||||
f"FPS {old_eff} → {self._effective_fps}"
|
||||
)
|
||||
next_frame_time = time.perf_counter()
|
||||
|
||||
if prev_reachable != reachable:
|
||||
logger.info(
|
||||
f"[PROBE] {self._target_id} device "
|
||||
f"{'reachable' if reachable else 'UNREACHABLE'}"
|
||||
)
|
||||
|
||||
# Fire new probe every _probe_interval seconds
|
||||
if (
|
||||
_probe_enabled
|
||||
and _probe_task is None
|
||||
and (now - _last_probe_time) >= _probe_interval
|
||||
):
|
||||
if _probe_client is not None:
|
||||
_last_probe_time = now
|
||||
_probe_task = asyncio.create_task(
|
||||
self._probe_device(_device_url, _probe_client)
|
||||
)
|
||||
|
||||
# Use effective FPS for frame timing
|
||||
# Use effective FPS for frame timing. ``self._effective_fps``
|
||||
# is mutated by the liveness probe task — read once.
|
||||
effective_fps = self._effective_fps if self._adaptive_fps else target_fps
|
||||
self._metrics.fps_effective = effective_fps
|
||||
frame_time = 1.0 / effective_fps
|
||||
@@ -981,8 +1042,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
await self._broadcast_led_preview(send_colors, cur_brightness)
|
||||
_last_preview_broadcast = now
|
||||
self._metrics.frames_skipped += 1
|
||||
self._metrics.fps_current = _fps_current_from_timestamps()
|
||||
await asyncio.sleep(SKIP_REPOLL)
|
||||
self._metrics.fps_current = _fps_current_from_timestamps()
|
||||
continue
|
||||
|
||||
# Force-send preview when a new client just connected
|
||||
@@ -1024,10 +1085,10 @@ class WledTargetProcessor(TargetProcessor):
|
||||
await self._broadcast_led_preview(send_colors, cur_brightness)
|
||||
_last_preview_broadcast = now
|
||||
self._metrics.frames_skipped += 1
|
||||
self._metrics.fps_current = _fps_current_from_timestamps()
|
||||
is_animated = stream.is_animated
|
||||
repoll = SKIP_REPOLL if is_animated else frame_time
|
||||
await asyncio.sleep(repoll)
|
||||
self._metrics.fps_current = _fps_current_from_timestamps()
|
||||
continue
|
||||
|
||||
prev_frame_ref = frame
|
||||
@@ -1150,9 +1211,9 @@ class WledTargetProcessor(TargetProcessor):
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Clean up probe client
|
||||
if _probe_client is not None:
|
||||
await _probe_client.aclose()
|
||||
# Stop the liveness probe task. ``_run_liveness_probe_loop``
|
||||
# owns its own httpx.AsyncClient via ``async with`` so cancelling
|
||||
# the task closes the client cleanly.
|
||||
if _probe_task is not None and not _probe_task.done():
|
||||
_probe_task.cancel()
|
||||
try:
|
||||
|
||||
@@ -588,6 +588,14 @@ class UpdateService:
|
||||
"body": rel.body,
|
||||
"prerelease": rel.prerelease,
|
||||
"published_at": rel.published_at,
|
||||
"assets": [
|
||||
{
|
||||
"name": a.name,
|
||||
"size": a.size,
|
||||
"download_url": a.download_url,
|
||||
}
|
||||
for a in rel.assets
|
||||
],
|
||||
}
|
||||
if rel
|
||||
else None
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user