Compare commits
25 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 |
@@ -54,6 +54,17 @@ jobs:
|
||||
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
|
||||
echo "Build label: $LABEL (release=$IS_RELEASE)"
|
||||
|
||||
- name: Guard release tag against missing keystore
|
||||
# Release tags MUST produce a release-signed APK, otherwise existing
|
||||
# installs can't upgrade (signature mismatch). Fail loudly instead
|
||||
# of silently falling back to the debug signing config.
|
||||
# Runs before JDK/Python/SDK/NDK setup so a misconfigured release
|
||||
# tag fails in seconds instead of after several minutes of setup.
|
||||
if: ${{ steps.label.outputs.is_release == 'true' && env.ANDROID_KEYSTORE_BASE64 == '' }}
|
||||
run: |
|
||||
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
|
||||
exit 1
|
||||
|
||||
- name: Setup JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
@@ -122,15 +133,6 @@ jobs:
|
||||
echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT"
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Guard release tag against missing keystore
|
||||
# Release tags MUST produce a release-signed APK, otherwise existing
|
||||
# installs can't upgrade (signature mismatch). Fail loudly instead
|
||||
# of silently falling back to the debug signing config.
|
||||
if: ${{ steps.label.outputs.is_release == 'true' && steps.keystore.outputs.present != 'true' }}
|
||||
run: |
|
||||
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
|
||||
exit 1
|
||||
|
||||
- name: Build APK
|
||||
working-directory: android
|
||||
env:
|
||||
|
||||
@@ -5,9 +5,15 @@ on:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
# Allow manual runs (e.g. to validate after a release commit was skipped).
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# Skip release-publishing commits — version bumps don't affect lint/tests
|
||||
# and the release.yml pipeline is already running. PRs and manual dispatch
|
||||
# always run.
|
||||
if: ${{ github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore: release') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -95,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.
|
||||
|
||||
+23
-25
@@ -1,27 +1,28 @@
|
||||
## v0.5.0 (2026-04-25)
|
||||
|
||||
This release ships the **Lumenworks studio-console** — a top-to-bottom WebUI redesign — plus a customizable per-account dashboard, a server-shutdown control, and a handful of dark/light/narrow-screen polish fixes.
|
||||
## v0.6.1 (2026-05-10)
|
||||
|
||||
### Features
|
||||
- **Lumenworks studio-console WebUI redesign** — new visual language across the entire WebUI: studio-console layout, refined typography, accent system, and motion. ([539e431](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/539e431))
|
||||
- Extend the Lumenworks treatment to the **Inputs**, **Integrations**, and **Graph** tabs so the redesign is consistent across all top-level views. ([b43e1cf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b43e1cf))
|
||||
- **Per-account customizable dashboard** with a slide-in configuration panel — each user can pick their own widget layout, persisted per account. ([56853b7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/56853b7))
|
||||
- Dashboard polish: richer performance strip, transport-bar controls, and additional readouts on the main view. ([e5a2af9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e5a2af9))
|
||||
- Item-card restyle with hover-driven performance tooltips and a configurable FPS ceiling. ([70c95d1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/70c95d1))
|
||||
- **Live card-color picker** — pick a custom color per card and see it apply instantly; default preset now uses the base palette. Monotonic uptime ticker no longer jitters on clock adjustments. ([e0ff40f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e0ff40f))
|
||||
- **Server shutdown action** exposed in the WebUI, backed by a public `cancel_task` lifecycle method so long-running tasks unwind cleanly. ([3f80ef2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3f80ef2))
|
||||
|
||||
- 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
|
||||
- Channel stripe on item cards now only paints when the card has a custom color or is running — no more stray accents on idle defaults. ([b1ee3c3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b1ee3c3))
|
||||
- Cards render correctly on pure black and pure white backgrounds, and are decoupled from the animated background so they stay legible regardless of the bg-anim setting. ([dd415e2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd415e2))
|
||||
- Single-row header layout and readable sidebar labels at narrow widths — fixes wrapping and label truncation on smaller windows. ([2bae304](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2bae304))
|
||||
|
||||
- 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
|
||||
|
||||
- Android: fail-fast on missing release keystore before SDK setup ([a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b))
|
||||
|
||||
#### Chores
|
||||
- Harden test isolation, add `.gitignore` rule for stale `src/data/`, and mark the shutdown action done in the task tracker. ([80f01d4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/80f01d4))
|
||||
|
||||
- Clean up `cfg` abbreviation and stale TODO link ([e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4))
|
||||
|
||||
---
|
||||
|
||||
@@ -30,16 +31,13 @@ This release ships the **Lumenworks studio-console** — a top-to-bottom WebUI r
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [80f01d4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/80f01d4) | chore: harden test isolation, gitignore stale src/data, mark shutdown action done | alexei.dolgolyov |
|
||||
| [b1ee3c3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b1ee3c3) | fix(ui): channel stripe paints only on custom-color or running cards | alexei.dolgolyov |
|
||||
| [e0ff40f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e0ff40f) | feat(ui): live card-color picker, monotonic uptime ticker tweaks, default preset uses base palette | alexei.dolgolyov |
|
||||
| [3f80ef2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3f80ef2) | feat: server shutdown action with public cancel_task lifecycle method | alexei.dolgolyov |
|
||||
| [2bae304](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2bae304) | fix(ui): single-row header + readable sidebar labels at narrow widths | alexei.dolgolyov |
|
||||
| [dd415e2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd415e2) | fix(ui): cards on pure black/white, decoupled from bg-anim | alexei.dolgolyov |
|
||||
| [b43e1cf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b43e1cf) | feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs | alexei.dolgolyov |
|
||||
| [56853b7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/56853b7) | feat(dashboard): per-account customizable dashboard with slide-in panel | alexei.dolgolyov |
|
||||
| [70c95d1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/70c95d1) | feat(ui): item-card restyle, perf hover tooltips, FPS ceiling | alexei.dolgolyov |
|
||||
| [e5a2af9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e5a2af9) | feat(ui): dashboard polish, richer perf strip, transport-bar controls | alexei.dolgolyov |
|
||||
| [539e431](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/539e431) | feat(ui): Lumenworks studio-console WebUI redesign | 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,155 @@
|
||||
# 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.
|
||||
@@ -374,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.5.0"
|
||||
versionName = "0.6.1"
|
||||
|
||||
ndk {
|
||||
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
||||
|
||||
+4
-1
@@ -162,8 +162,11 @@ Section "Desktop shortcut" SecDesktop
|
||||
SectionEnd
|
||||
|
||||
Section "Start with Windows" SecAutostart
|
||||
; Pass --autostart so the VBS sets LEDGRAB_AUTOSTART=1 and the app suppresses
|
||||
; the browser auto-open on Windows login. Manual launches (desktop / start
|
||||
; menu) don't pass the arg, so they keep opening the WebUI tab.
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}" --autostart' \
|
||||
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
||||
# Refactor Plan: Per-Provider Typed Device Configs
|
||||
|
||||
**Status:** Planned, not started.
|
||||
**Target branch:** `refactor/device-typed-configs`
|
||||
**Intended executor:** Sonnet agent (one phase per invocation; human review between phases).
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the flat [`DeviceInfo`](../../server/src/ledgrab/core/processing/target_processor.py) dataclass (and the `**kwargs`-based `LEDDeviceProvider.create_client(url, **kwargs)` contract) with a **discriminated union of per-provider config dataclasses**. Each provider owns its config type and reads typed fields instead of guessing kwargs.
|
||||
|
||||
## Motivation
|
||||
|
||||
Current pain points:
|
||||
|
||||
- [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) unpacks ~21 fields by hand into `create_led_client(**kwargs)`.
|
||||
- Every provider's `create_client` starts with `kwargs.get("x", default)` — no type safety, no IDE hints, no way to know at a glance which fields a provider actually uses.
|
||||
- Adding a new per-device-type field requires threading it through `Device` → `DeviceInfo` → `_DEVICE_FIELD_DEFAULTS` → call-site unpacking → kwargs bag → provider.
|
||||
- Fields leak across device types (a WLED device carries `ble_govee_key=""` at runtime for no reason).
|
||||
|
||||
## Scope guardrails
|
||||
|
||||
- **Storage schema (SQLite) unchanged.** Columns stay, dead-for-this-type fields stay, no destructive migration.
|
||||
- **Frontend HTML/TS unchanged in phases 1-4.** It already branches on `device_type` with show/hide logic. Frontend changes are deferred to Phase 5.
|
||||
- **API schemas are last.** Phase 5 converts `DeviceCreate`/`DeviceUpdate`/`DeviceResponse` to a Pydantic v2 discriminated union. This is the only breaking external change and can be deferred indefinitely if needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Config hierarchy (foundation, non-breaking)
|
||||
|
||||
### Create
|
||||
|
||||
**File:** `server/src/ledgrab/core/devices/device_config.py`
|
||||
|
||||
Pattern:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Literal, Optional, Union
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BaseDeviceConfig:
|
||||
device_id: str
|
||||
device_url: str
|
||||
led_count: int
|
||||
software_brightness: int = 255
|
||||
test_mode_active: bool = False
|
||||
auto_shutdown: bool = False
|
||||
rgbw: bool = False
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WLEDConfig(BaseDeviceConfig):
|
||||
device_type: Literal["wled"] = "wled"
|
||||
use_ddp: bool = False
|
||||
|
||||
# ... one @dataclass(frozen=True) per provider
|
||||
```
|
||||
|
||||
### Config field inventory
|
||||
|
||||
Base: `device_id`, `device_url`, `led_count`, `software_brightness`, `test_mode_active`, `auto_shutdown`, `rgbw`.
|
||||
|
||||
| Config | Extra fields beyond Base |
|
||||
| -------------- | ------------------------ |
|
||||
| WLEDConfig | `use_ddp: bool = False` |
|
||||
| AdalightConfig | `baud_rate: Optional[int] = None` |
|
||||
| AmbiLEDConfig | `baud_rate: Optional[int] = None` |
|
||||
| DMXConfig | `dmx_protocol`, `dmx_start_universe`, `dmx_start_channel` |
|
||||
| ESPNowConfig | `baud_rate`, `espnow_peer_mac`, `espnow_channel` |
|
||||
| HueConfig | `hue_username`, `hue_client_key`, `hue_entertainment_group_id` |
|
||||
| SPIConfig | `spi_speed_hz`, `spi_led_type` |
|
||||
| ChromaConfig | `chroma_device_type` |
|
||||
| GameSenseConfig| `gamesense_device_type` |
|
||||
| BLEConfig | `ble_family`, `ble_govee_key` |
|
||||
| GroupConfig | `group_mode`, `group_device_ids` (**no `device_store` here** — see Phase 2) |
|
||||
| OpenRGBConfig | `zone_mode` |
|
||||
| MockConfig | `send_latency_ms: int = 0` |
|
||||
| DemoConfig | `send_latency_ms: int = 0` |
|
||||
| MQTTConfig | (none) |
|
||||
| WSConfig | (none) |
|
||||
| USBHIDConfig | (none — `hid_usage_page` is parsed from the URL, not config) |
|
||||
|
||||
```python
|
||||
DeviceConfig = Union[
|
||||
WLEDConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, ESPNowConfig,
|
||||
HueConfig, SPIConfig, ChromaConfig, GameSenseConfig, BLEConfig,
|
||||
GroupConfig, MQTTConfig, WSConfig, USBHIDConfig, OpenRGBConfig,
|
||||
MockConfig, DemoConfig,
|
||||
]
|
||||
```
|
||||
|
||||
### Add
|
||||
|
||||
**`Device.to_config() -> DeviceConfig`** in [server/src/ledgrab/storage/device_store.py](../../server/src/ledgrab/storage/device_store.py) (around lines 14-97 where `Device` lives).
|
||||
|
||||
- Dispatches on `self.device_type`.
|
||||
- Constructs the right subclass, pulling only relevant columns.
|
||||
- Ignores columns that don't apply to the type.
|
||||
- This is the **only** place that knows the flat→typed mapping.
|
||||
|
||||
### Do NOT touch in Phase 1
|
||||
|
||||
- Provider signatures (still `create_client(self, url, **kwargs)`).
|
||||
- `create_led_client` factory.
|
||||
- Any call site.
|
||||
- `DeviceInfo` itself.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- New unit test `server/tests/core/devices/test_device_config.py`:
|
||||
- For each provider, build a `Device` with that `device_type`, call `to_config()`, assert right subclass and right fields.
|
||||
- Edge case: extra/irrelevant Device fields must not leak into the wrong config type.
|
||||
- `cd server && ruff check src/ tests/ --fix` — green.
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green (existing tests untouched, new test passes).
|
||||
- `cd server && npx tsc --noEmit` — green (no TS impact this phase, just a sanity check).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 + Phase 3 — Provider API migration + call-site migration (single PR)
|
||||
|
||||
**These must land in one commit** because the provider signature change would otherwise break the 3 call sites immediately.
|
||||
|
||||
### Change the abstract base
|
||||
|
||||
[server/src/ledgrab/core/devices/led_client.py](../../server/src/ledgrab/core/devices/led_client.py):
|
||||
|
||||
```python
|
||||
class LEDDeviceProvider(ABC):
|
||||
@abstractmethod
|
||||
def create_client(self, config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient: ...
|
||||
```
|
||||
|
||||
`ProviderDeps` is a tiny new dataclass:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDeps:
|
||||
device_store: "DeviceStore"
|
||||
# Add future cross-cutting runtime deps here (http_client, etc.)
|
||||
```
|
||||
|
||||
`create_led_client`:
|
||||
|
||||
```python
|
||||
def create_led_client(config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient:
|
||||
return get_provider(config.device_type).create_client(config, deps=deps)
|
||||
```
|
||||
|
||||
### Update every provider (17 files)
|
||||
|
||||
- Narrow signature per provider: e.g. `WLEDDeviceProvider.create_client(self, config: WLEDConfig, *, deps: ProviderDeps)`.
|
||||
- Drop all `kwargs.get("x")` lookups — read typed fields directly.
|
||||
- Providers that don't need `deps` just ignore it.
|
||||
- **GroupDeviceProvider** is the only current consumer of `deps`: reads `deps.device_store`.
|
||||
|
||||
### Call sites (3)
|
||||
|
||||
1. [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) lines ~120-148 — the 21-field unpacking. Replace with:
|
||||
```python
|
||||
config = device.to_config()
|
||||
self._led_client = create_led_client(config, deps=self._provider_deps)
|
||||
```
|
||||
`self._provider_deps` is plumbed in from `ProcessorManager` when the target processor is constructed.
|
||||
2. [server/src/ledgrab/core/processing/device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78 — minimal test-mode client. Build a synthetic config via a helper `_minimal_config_for_test_mode(device)` (keeps just `device_id`, `device_url`, `led_count`, `baud_rate`) and pass it.
|
||||
3. [server/src/ledgrab/core/devices/group_client.py](../../server/src/ledgrab/core/devices/group_client.py) lines 47-70 — child client construction inside the group. Same pattern: `child_config = child_device.to_config()`; pass `deps` through.
|
||||
|
||||
### Delete
|
||||
|
||||
- `DeviceInfo` dataclass in [server/src/ledgrab/core/processing/target_processor.py](../../server/src/ledgrab/core/processing/target_processor.py) lines 71-109.
|
||||
- `ProcessorManager._get_device_info()` and `_DEVICE_FIELD_DEFAULTS` in [server/src/ledgrab/core/processing/processor_manager.py](../../server/src/ledgrab/core/processing/processor_manager.py) lines 230-275 — `Device.to_config()` subsumes this. Verify no other callers via `ast-index usages "_get_device_info"`.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `ast-index search "device_info\."` — no hits in non-test code.
|
||||
- `ast-index search "DeviceInfo"` — no hits outside archival comments.
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all tests pass.
|
||||
- Manual smoke: start server, create a WLED device, start processing, verify LEDs update (or mock output shows frames).
|
||||
- `cd server && ruff check src/ tests/ --fix` — green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Test migration
|
||||
|
||||
Update these files:
|
||||
|
||||
- `server/tests/storage/test_device_store.py` — add `to_config()` cases per device type.
|
||||
- `server/tests/api/routes/test_devices_routes.py` — should be mostly untouched (API schemas still flat until Phase 5).
|
||||
- `server/tests/e2e/test_device_flow.py` — update internal assertions only if they touch `DeviceInfo` directly.
|
||||
- `server/tests/test_group_device.py` — construct child clients with `GroupConfig`.
|
||||
- Any fixture helper that builds a fake `DeviceInfo` — migrate to the right `*Config` subclass.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all green.
|
||||
- Coverage of `device_config.py` and `Device.to_config()` ≥ 90%.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — API discriminated union (OPTIONAL, separate PR)
|
||||
|
||||
**Do not start until Phases 1-4 are merged and stable.** Flag this to the human before beginning. This is the only phase with an externally breaking change.
|
||||
|
||||
### Backend
|
||||
|
||||
[server/src/ledgrab/api/schemas/devices.py](../../server/src/ledgrab/api/schemas/devices.py) — replace flat `DeviceCreate`/`DeviceUpdate` with Pydantic v2 tagged unions:
|
||||
|
||||
```python
|
||||
class WLEDDeviceCreate(BaseModel):
|
||||
device_type: Literal["wled"]
|
||||
name: str
|
||||
url: str
|
||||
led_count: int
|
||||
use_ddp: bool = False
|
||||
# ... base fields only
|
||||
|
||||
DeviceCreate = Annotated[
|
||||
Union[WLEDDeviceCreate, AdalightDeviceCreate, ...],
|
||||
Field(discriminator="device_type"),
|
||||
]
|
||||
```
|
||||
|
||||
Add `model_config = ConfigDict(extra="ignore")` on each union member for **one release cycle** so existing clients (frontend, HAOS integration, curl scripts) that send extra fields don't 422 immediately. Add a deprecation note and tighten to `extra="forbid"` in a follow-up.
|
||||
|
||||
### Frontend
|
||||
|
||||
- [server/src/ledgrab/static/js/features/devices.ts](../../server/src/ledgrab/static/js/features/devices.ts) and related — when building the POST/PATCH body, scope the payload to the selected `device_type` using the show/hide knowledge already in `device-discovery.ts`.
|
||||
- **No plain `<select>` elements** — any new pickers use IconSelect or EntitySelect (see root CLAUDE.md UI rules).
|
||||
|
||||
### Tests
|
||||
|
||||
- Update `test_devices_routes.py` to assert discriminated union rejection of mismatched shapes.
|
||||
- Add round-trip tests: create device of each type via API → fetch → compare fields.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green.
|
||||
- `cd server && npx tsc --noEmit && npm run build` — green.
|
||||
- Manual smoke for at least 3 device types (WLED, DMX, Hue) — create, edit, delete via UI.
|
||||
- HAOS integration still works against the server (spot-check; not automated).
|
||||
|
||||
---
|
||||
|
||||
## Conventions the implementing agent must follow
|
||||
|
||||
- **Project task tracker is `TODO.md`** — check the "Refactor: Per-Provider Device Configs" section, tick boxes as phases land. Do **not** use the `TodoWrite` tool.
|
||||
- **Auto-restart after Python changes.** See [contexts/server-operations.md](../../contexts/server-operations.md).
|
||||
- **No commits without explicit user approval.** Present each phase's diff for review first.
|
||||
- **Pre-commit gate every phase:**
|
||||
- `cd server && ruff check src/ tests/ --fix`
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q`
|
||||
- Phase 5 additionally: `cd server && npx tsc --noEmit && npm run build`
|
||||
- **No plain `<select>`** — Phase 5 uses IconSelect / EntitySelect.
|
||||
- **Android parity:** if you add any new runtime dep to `server/pyproject.toml`, update `android/app/build.gradle.kts` per the root [CLAUDE.md](../../CLAUDE.md) "Android Dependency Sync" section. This refactor should not need any new deps.
|
||||
- **Data migration policy:** storage schema is unchanged, so no JSON-file migration is needed. But if you rename any serialized field during `to_dict`/`from_dict`, add migration logic per the root [CLAUDE.md](../../CLAUDE.md) "Data Migration Policy" section.
|
||||
- **Use `ast-index`** for code search (`ast-index search`, `ast-index usages`, `ast-index callers`, `ast-index class`). Fall back to Grep only for regex/string-literal/comment searches.
|
||||
- **Never run `cd` in Bash.** Use absolute paths or the project-relative `cd server && <cmd>` idiom (one-shot, same invocation).
|
||||
|
||||
## Known risks
|
||||
|
||||
1. **Frozen dataclass + inheritance + defaults** — Python's `@dataclass(frozen=True)` with inheritance requires every subclass field to have a default if any parent field does. Base has defaulted fields. Verify in Phase 1. If it breaks, use `kw_only=True` (Python 3.10+).
|
||||
2. **`use_ddp` origin** — currently inferred from `self._protocol == "ddp"` at the call site, not from Device storage. Options: add a column (schema change, more work), **or** keep inference logic inside `Device.to_config()` (recommended — no schema change). Prefer the latter.
|
||||
3. **Test-mode minimal client** ([device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78) may not have all `BaseDeviceConfig` fields available. Build a synthetic config via a named helper; do not leak the hack into `Device.to_config()`.
|
||||
4. **Group `device_store` import cycle** — `GroupConfig` must **not** hold `device_store` (would pull storage into the config module). `ProviderDeps` is the deliberate cut.
|
||||
5. **BLE optional import** — `BLEDeviceProvider` is conditionally registered (see [led_client.py](../../server/src/ledgrab/core/devices/led_client.py) lines 321-330). Ensure `BLEConfig` still imports cleanly even when `bleak` is absent — put `BLEConfig` in `device_config.py` (not in `ble_provider.py`) so it's always importable.
|
||||
|
||||
## Deliverables per phase
|
||||
|
||||
1. Branch: `refactor/device-typed-configs`.
|
||||
2. One commit per phase, conventional-commit messages:
|
||||
- `refactor(devices): phase 1 — add DeviceConfig hierarchy`
|
||||
- `refactor(devices): phases 2+3 — typed provider signatures + call-site migration`
|
||||
- `refactor(devices): phase 4 — test migration to typed configs`
|
||||
- `refactor(devices): phase 5 — API discriminated union` (separate PR)
|
||||
3. Phase-by-phase diffs presented for user review **before** each commit.
|
||||
4. Final PR body linking all phases, with manual test plan per device type touched.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ledgrab"
|
||||
version = "0.5.0"
|
||||
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"
|
||||
|
||||
@@ -83,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:
|
||||
@@ -120,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,),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
"""User preferences routes — currently dashboard layout only.
|
||||
"""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
|
||||
|
||||
@@ -20,6 +36,34 @@ 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(
|
||||
@@ -73,3 +117,172 @@ async def delete_dashboard_layout(
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,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
|
||||
|
||||
@@ -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
|
||||
|
||||
+58
-30
@@ -57,6 +57,7 @@ import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-re
|
||||
from ledgrab.core.devices.mqtt_client import set_mqtt_service
|
||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||
from ledgrab.core.processing.os_notification_listener import OsNotificationListener
|
||||
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
|
||||
from ledgrab.core.update.update_service import UpdateService
|
||||
from ledgrab.core.update.gitea_provider import GiteaReleaseProvider
|
||||
from ledgrab.storage.database import Database
|
||||
@@ -365,6 +366,24 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
os_notif_listener.start()
|
||||
|
||||
# Start background discovery watcher (mDNS + serial polling).
|
||||
# Gated by user pref; default is on. The watcher emits
|
||||
# device_discovered/device_lost events through the same fire_event
|
||||
# bus that the health monitor uses for device_health_changed.
|
||||
from ledgrab.api.routes.preferences import load_notification_preferences
|
||||
|
||||
discovery_watcher: DiscoveryWatcher | None = None
|
||||
try:
|
||||
notif_prefs = load_notification_preferences(db)
|
||||
if notif_prefs.background_discovery_enabled:
|
||||
discovery_watcher = DiscoveryWatcher(
|
||||
device_store=device_store,
|
||||
fire_event=processor_manager.fire_event,
|
||||
)
|
||||
await discovery_watcher.start()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start discovery watcher: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
@@ -375,45 +394,34 @@ async def lifespan(app: FastAPI):
|
||||
# where no CRUD happened during the session.
|
||||
_save_all_stores()
|
||||
|
||||
# Stop Home Assistant manager
|
||||
try:
|
||||
await ha_manager.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping Home Assistant manager: {e}")
|
||||
|
||||
# Stop weather manager
|
||||
try:
|
||||
weather_manager.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping weather manager: {e}")
|
||||
|
||||
# Stop update checker
|
||||
try:
|
||||
await update_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping update checker: {e}")
|
||||
|
||||
# Stop auto-backup engine
|
||||
try:
|
||||
await auto_backup_engine.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping auto-backup engine: {e}")
|
||||
|
||||
# Stop automation engine first (deactivates automation-managed scenes)
|
||||
# Stop automation engine first so it can no longer activate scenes that
|
||||
# would talk to processors mid-shutdown.
|
||||
try:
|
||||
await automation_engine.stop()
|
||||
logger.info("Stopped automation engine")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping automation engine: {e}")
|
||||
|
||||
# Stop OS notification listener
|
||||
# Stop discovery watcher and OS notification listener so they stop
|
||||
# firing events into a shutting-down processor manager.
|
||||
if discovery_watcher is not None:
|
||||
try:
|
||||
await discovery_watcher.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping discovery watcher: {e}")
|
||||
|
||||
try:
|
||||
os_notif_listener.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping OS notification listener: {e}")
|
||||
|
||||
# Stop all processing.
|
||||
# The shutdown action setting controls whether per-device restore
|
||||
# Stop all processing BEFORE tearing down ha_manager / mqtt_manager /
|
||||
# mqtt_service. HA-light targets need a live HA runtime to apply their
|
||||
# stop_action (turn_off / restore), and MQTT-output devices need a live
|
||||
# MQTT broker connection to send restore frames. Shutting those down
|
||||
# first silently turns "stop_targets" into a no-op for those targets.
|
||||
#
|
||||
# The shutdown_action setting controls whether per-device restore
|
||||
# frames are sent: "stop_targets" (default) runs the normal stop
|
||||
# sequence; "nothing" cancels capture tasks so the LEDs freeze on
|
||||
# their last frame.
|
||||
@@ -432,18 +440,38 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping processors: {e}")
|
||||
|
||||
# Stop MQTT manager (entity-based broker connections)
|
||||
# Now safe to tear down the connections that processors depended on.
|
||||
try:
|
||||
await ha_manager.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping Home Assistant manager: {e}")
|
||||
|
||||
try:
|
||||
await mqtt_manager.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping MQTT manager: {e}")
|
||||
|
||||
# Stop MQTT service (legacy global connection)
|
||||
try:
|
||||
await mqtt_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping MQTT service: {e}")
|
||||
|
||||
# Independent services — order doesn't matter relative to processors.
|
||||
try:
|
||||
weather_manager.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping weather manager: {e}")
|
||||
|
||||
try:
|
||||
await update_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping update checker: {e}")
|
||||
|
||||
try:
|
||||
await auto_backup_engine.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping auto-backup engine: {e}")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@import './advanced-calibration.css';
|
||||
@import './dashboard.css';
|
||||
@import './dashboard-customize.css';
|
||||
@import './card-modes.css';
|
||||
@import './streams.css';
|
||||
@import './patterns.css';
|
||||
@import './automations.css';
|
||||
|
||||
@@ -18,88 +18,95 @@ h1 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive preset grid — matches the mockup's tight 4-up rhythm
|
||||
on desktop and gracefully reflows on narrow viewports. */
|
||||
.ap-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ─── Preset card (shared) ─── */
|
||||
|
||||
.ap-card {
|
||||
--ap-ch: var(--ch-magenta, #ff4ade);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card-bg);
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
padding: 4px 4px 3px;
|
||||
border: 1px solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-md, 8px);
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
cursor: pointer;
|
||||
transition: border-color var(--duration-normal) var(--ease-out),
|
||||
box-shadow var(--duration-normal) var(--ease-out),
|
||||
transform var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
.ap-card.ap-card-bg { --ap-ch: var(--ch-cyan, #00d8ff); }
|
||||
|
||||
.ap-card:hover {
|
||||
border-color: var(--text-muted);
|
||||
border-color: color-mix(in srgb, var(--ap-ch) 50%, var(--lux-line, var(--border-color)));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ap-card.active {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px var(--primary-color),
|
||||
0 0 12px -2px color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
border: 2px solid var(--ap-ch);
|
||||
padding: 3px 3px 2px;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ap-ch) 40%, transparent),
|
||||
0 0 16px -4px color-mix(in srgb, var(--ap-ch) 50%, transparent);
|
||||
}
|
||||
|
||||
.ap-card.active::after {
|
||||
content: '\2713';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
font-size: 0.65rem;
|
||||
top: 3px;
|
||||
right: 4px;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
color: var(--ap-ch);
|
||||
}
|
||||
|
||||
.ap-card-label {
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ap-card.active .ap-card-label {
|
||||
color: var(--primary-color);
|
||||
color: var(--ap-ch);
|
||||
}
|
||||
|
||||
/* ─── Style preset preview ─── */
|
||||
|
||||
.ap-card-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-sm);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--lux-r-sm, 4px);
|
||||
border: 1px solid;
|
||||
padding: 8px 7px 6px;
|
||||
padding: 5px 5px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ap-card-accent {
|
||||
width: 24px;
|
||||
height: 4px;
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.ap-card-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ap-card-lines span {
|
||||
@@ -113,12 +120,12 @@ h1 {
|
||||
|
||||
.ap-bg-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-sm);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--lux-r-sm, 4px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--lux-line, var(--border-color));
|
||||
}
|
||||
|
||||
.ap-bg-preview-inner {
|
||||
|
||||
@@ -1,48 +1,23 @@
|
||||
/* ===== AUTOMATIONS ===== */
|
||||
|
||||
.badge-automation-active {
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
|
||||
}
|
||||
|
||||
.badge-automation-inactive {
|
||||
background: transparent;
|
||||
border-color: var(--lux-line, var(--border-color));
|
||||
color: var(--lux-ink-dim, var(--text-color));
|
||||
}
|
||||
|
||||
.badge-automation-disabled {
|
||||
background: transparent;
|
||||
border-color: var(--lux-line, var(--border-color));
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.automation-status-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.automation-logic-label {
|
||||
font-size: 0.7rem;
|
||||
/* Chain-arrow separator — slips between chips on the AUTO card to
|
||||
render the rule flow visually (rule + rule → scene ↩ revert).
|
||||
Used inside .mod-chips, channel-tinted via the parent's --ch. */
|
||||
.mod-card .chain-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--ch);
|
||||
opacity: 0.65;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Automation rule pills — constrain to card width */
|
||||
[data-automation-id] .card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
[data-automation-id] .stream-card-prop {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.04em;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Automation rule editor rows */
|
||||
|
||||
@@ -43,6 +43,28 @@
|
||||
--space-lg: 20px;
|
||||
--space-xl: 40px;
|
||||
|
||||
/* ── Card grid sizing ──────────────────────────────────────────────
|
||||
Tokens for the auto-fill card grids (devices, displays, dashboard
|
||||
targets, integrations, autostart). Defaults reproduce the values
|
||||
that were inline before tokenization, so this layer is a no-op
|
||||
until the card-mode toggle wires `[data-card-mode=…]` overrides.
|
||||
|
||||
· `*-min` — minmax() column width for the main module
|
||||
cards (devices, displays, dashboard targets/scenes).
|
||||
· `*-min-narrow` — column width for slimmer dashboard-module
|
||||
rows (integrations, autostart).
|
||||
· `*-gap` / `*-gap-narrow` — corresponding row/column gap. */
|
||||
--card-grid-min: 380px;
|
||||
--card-grid-gap: 14px;
|
||||
--card-grid-min-narrow: 320px;
|
||||
--card-grid-gap-narrow: 12px;
|
||||
|
||||
/* Capture-template / source-card grids (sources, streams, templates,
|
||||
color strips) have their own column proportions so they stay
|
||||
distinct from device/target cards. */
|
||||
--templates-grid-min: 350px;
|
||||
--templates-grid-gap: 20px;
|
||||
|
||||
/* Border radius */
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* Card presentation modes — `[data-card-mode="comfortable|compact|dense"]`
|
||||
*
|
||||
* Sister to the dashboard `[data-density]` system (which governs section
|
||||
* header/gap only). This file targets the **card grids and the cards
|
||||
* themselves**: column min-width, gap, internal padding, and which
|
||||
* mod-* blocks render visibly.
|
||||
*
|
||||
* Apply the attribute to the grid container OR any ancestor (the page
|
||||
* tab, the section). All tokens cascade.
|
||||
*
|
||||
* <section data-card-mode="dense">
|
||||
* <div class="devices-grid">…</div>
|
||||
* </section>
|
||||
*
|
||||
* <div class="dashboard-section" data-density="dense" data-card-mode="dense">
|
||||
* …
|
||||
* </div>
|
||||
*
|
||||
* Defaults (= `compact`) live in base.css :root; this file only overrides
|
||||
* for `comfortable` and `dense`. Default mode is implicit; the attribute
|
||||
* may be omitted on grids that haven't migrated yet.
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Comfortable: roomier columns, expanded card padding ─────────── */
|
||||
[data-card-mode="comfortable"] {
|
||||
--card-grid-min: 440px;
|
||||
--card-grid-gap: 18px;
|
||||
--card-grid-min-narrow: 360px;
|
||||
--card-grid-gap-narrow: 16px;
|
||||
--templates-grid-min: 400px;
|
||||
--templates-grid-gap: 22px;
|
||||
}
|
||||
|
||||
[data-card-mode="comfortable"] .card,
|
||||
[data-card-mode="comfortable"] .template-card {
|
||||
padding: 22px 24px 20px;
|
||||
}
|
||||
|
||||
[data-card-mode="comfortable"] .dashboard-target:has(.mod-head) {
|
||||
padding: 20px 22px 18px 26px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
[data-card-mode="comfortable"] .dashboard-autostart:has(.mod-head),
|
||||
[data-card-mode="comfortable"] .dashboard-integration:has(.mod-head) {
|
||||
padding: 18px 20px 16px;
|
||||
}
|
||||
|
||||
/* ── Dense: tight columns, slim padding, hide auxiliary mod-* blocks ── */
|
||||
[data-card-mode="dense"] {
|
||||
--card-grid-min: 260px;
|
||||
--card-grid-gap: 8px;
|
||||
--card-grid-min-narrow: 220px;
|
||||
--card-grid-gap-narrow: 6px;
|
||||
--templates-grid-min: 240px;
|
||||
--templates-grid-gap: 10px;
|
||||
}
|
||||
|
||||
[data-card-mode="dense"] .card,
|
||||
[data-card-mode="dense"] .template-card {
|
||||
padding: 12px 14px 10px;
|
||||
}
|
||||
|
||||
[data-card-mode="dense"] .dashboard-target:has(.mod-head) {
|
||||
padding: 10px 14px 10px 18px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-card-mode="dense"] .dashboard-autostart:has(.mod-head),
|
||||
[data-card-mode="dense"] .dashboard-integration:has(.mod-head) {
|
||||
padding: 10px 12px 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Auxiliary content drops out in dense — keeps identity (icon, name,
|
||||
badge, dot) and primary control surfaces, sheds preview + secondary
|
||||
text. The actual data-bearing metric row is preserved. */
|
||||
[data-card-mode="dense"] .mod-leds {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-card-mode="dense"] .mod-head {
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
[data-card-mode="dense"] .mod-foot {
|
||||
padding-top: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Secondary text-button labels collapse to icon-only in dense; icon-only
|
||||
buttons and the kebab menu are unaffected. Primary action keeps its
|
||||
label so the "what does this card do" affordance survives. */
|
||||
[data-card-mode="dense"] .mod-btn:not(.mod-btn-icon):not(.mod-btn-primary) .mod-btn-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-card-mode="dense"] .mod-metrics {
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
[data-card-mode="dense"] .mod-metric .k {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
[data-card-mode="dense"] .mod-metric .v {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* Dashed corner bracket and channel stripe stay — they are the card's
|
||||
identity even at small sizes. No display:none on ::before / ::after. */
|
||||
|
||||
/* ── Row: single column, full-width stacked list ─────────────────────
|
||||
* Differs from `dense` in layout, not just padding: the grid collapses
|
||||
* to one column so every card spans the available width. Cards keep
|
||||
* their column-flex internals (mod-head → metrics → foot) — a true
|
||||
* horizontal row layout would require rewriting the mod-card vocabulary
|
||||
* and is a separate future mode.
|
||||
* ────────────────────────────────────────────────────────────────── */
|
||||
[data-card-mode="row"] {
|
||||
--card-grid-min: 100%;
|
||||
--card-grid-gap: 6px;
|
||||
--card-grid-min-narrow: 100%;
|
||||
--card-grid-gap-narrow: 6px;
|
||||
--templates-grid-min: 100%;
|
||||
--templates-grid-gap: 6px;
|
||||
}
|
||||
|
||||
[data-card-mode="row"] .card,
|
||||
[data-card-mode="row"] .template-card {
|
||||
padding: 10px 14px 10px;
|
||||
}
|
||||
|
||||
[data-card-mode="row"] .dashboard-target:has(.mod-head) {
|
||||
padding: 10px 14px 10px 18px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-card-mode="row"] .dashboard-autostart:has(.mod-head),
|
||||
[data-card-mode="row"] .dashboard-integration:has(.mod-head) {
|
||||
padding: 10px 12px 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Same auxiliary trims as `dense` — the row layout is information-dense
|
||||
by nature, so secondary visuals drop out for the same reasons. */
|
||||
[data-card-mode="row"] .mod-leds {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-card-mode="row"] .mod-head {
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
[data-card-mode="row"] .mod-foot {
|
||||
padding-top: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-card-mode="row"] .mod-btn:not(.mod-btn-icon):not(.mod-btn-primary) .mod-btn-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-card-mode="row"] .mod-metrics {
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
[data-card-mode="row"] .mod-metric .k {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
[data-card-mode="row"] .mod-metric .v {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* Segmented `C / M / D` toggle — sibling of dash-cust-density but
|
||||
* standalone so any section header / page toolbar can host one without
|
||||
* pulling in the dashboard-customize stylesheet.
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
.card-mode-toggle {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-sm, var(--radius-sm));
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
}
|
||||
|
||||
.card-mode-toggle__btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
font: 600 0.7rem/1 var(--font-mono, monospace);
|
||||
letter-spacing: 0.04em;
|
||||
padding: 4px 8px;
|
||||
min-width: 22px;
|
||||
cursor: pointer;
|
||||
border-radius: calc(var(--lux-r-sm, var(--radius-sm)) - 1px);
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.card-mode-toggle__btn:hover {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: var(--hover-bg, rgba(255, 255, 255, 0.04));
|
||||
}
|
||||
|
||||
.card-mode-toggle__btn.is-active {
|
||||
color: var(--primary-contrast, #fff);
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.card-mode-toggle__btn:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ section {
|
||||
.displays-grid,
|
||||
.devices-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min, 380px), 100%), 1fr));
|
||||
gap: var(--card-grid-gap, 14px);
|
||||
}
|
||||
|
||||
.devices-grid > .loading,
|
||||
@@ -147,13 +147,15 @@ section {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Channel stripe on left edge — opt-in only:
|
||||
/* Channel stripe on left edge — always on at .6 opacity for cards
|
||||
* that adopt the modular markup (.mod-card), opt-in for legacy cards.
|
||||
* [data-has-color="1"] → user picked a personal color via the picker
|
||||
* .card-running → "patched and live" indicator
|
||||
* Idle cards without a personal color stay clean (no stripe), matching
|
||||
* the pre-redesign behavior where the left border meant "I marked this".
|
||||
* The dashboard module rows keep their always-on stripe (at 0.6 opacity)
|
||||
* because the dashboard was approved as-is. */
|
||||
* .mod-card → ambient channel signal (matches dashboard)
|
||||
* Legacy cards without a personal color stay clean to avoid breaking
|
||||
* the visual rhythm of feature tabs that haven't migrated yet. Once
|
||||
* every create*Card() builder uses wrapCard({ mod }), the .mod-card
|
||||
* scope can be dropped and the stripe becomes ambient on every card. */
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -164,13 +166,20 @@ section {
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
display: none;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease, box-shadow 0.2s ease, width 0.2s ease;
|
||||
}
|
||||
|
||||
.card[data-has-color="1"]::before,
|
||||
.card.card-running::before {
|
||||
.card.card-running::before,
|
||||
.card.mod-card::before {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card.mod-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Corner bracket — silkscreened panel feel in the top-right */
|
||||
.card::after {
|
||||
content: '';
|
||||
@@ -1112,6 +1121,14 @@ body.cs-drag-active .card-drag-handle {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mod-card brightness fader — loading + disabled states */
|
||||
.card.mod-card.brightness-loading .mod-fader,
|
||||
.template-card.mod-card.brightness-loading .mod-fader,
|
||||
.mod-fader:has(.mod-fader__slider:disabled) {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Static color picker — inline in card-subtitle */
|
||||
.section-header {
|
||||
display: flex;
|
||||
@@ -1236,39 +1253,8 @@ ul.section-tip li {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Collapsible target pipeline metrics */
|
||||
.target-metrics-collapse {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.target-metrics-toggle {
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
user-select: none;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.target-metrics-toggle::before {
|
||||
content: '▸ ';
|
||||
}
|
||||
.target-metrics-collapse.open .target-metrics-toggle::before {
|
||||
content: '▾ ';
|
||||
}
|
||||
.target-metrics-animate {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.25s ease;
|
||||
}
|
||||
.target-metrics-collapse.open .target-metrics-animate {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.target-metrics-animate > .target-metrics-expanded {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Timing breakdown bar */
|
||||
/* Timing breakdown bar — used by graph-editor and the api-input test
|
||||
modal; the targets card uses its own .target-pipeline wrapper. */
|
||||
.timing-breakdown {
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
@@ -1533,81 +1519,258 @@ ul.section-tip li {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Selected card highlight */
|
||||
/* Selected card highlight — accent ring, soft outer glow, and a subtle
|
||||
lift so the chosen card pops above the dimmed siblings. */
|
||||
.cs-selecting .card-selected,
|
||||
.cs-selecting .card-selected.template-card {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px var(--primary-color), 0 4px 12px color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--primary-color),
|
||||
0 0 24px color-mix(in srgb, var(--primary-color) 22%, transparent),
|
||||
0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Make cards visually clickable in selection mode */
|
||||
/* Non-selected siblings during selection: desaturate, dim and softly blur
|
||||
so the eye locks onto the active picks. Hover restores full clarity to
|
||||
keep the card affordance obvious. */
|
||||
.cs-selecting .card:not(.card-selected),
|
||||
.cs-selecting .template-card:not(.card-selected) {
|
||||
opacity: 0.55;
|
||||
filter: saturate(0.55) blur(0.4px);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
filter 0.25s ease,
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.cs-selecting .card:not(.card-selected):hover,
|
||||
.cs-selecting .template-card:not(.card-selected):hover {
|
||||
opacity: 0.92;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cs-selecting .card:not(.card-selected),
|
||||
.cs-selecting .template-card:not(.card-selected) {
|
||||
filter: saturate(0.55);
|
||||
}
|
||||
}
|
||||
|
||||
/* Make cards visually clickable in selection mode — card root is the
|
||||
click target, descendants are inert. */
|
||||
.cs-selecting .card,
|
||||
.cs-selecting .template-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* All card descendants pass clicks through to the card root in
|
||||
selection mode. This disables buttons, sliders, kebab, color dot,
|
||||
and any other interactive child without needing per-element rules. */
|
||||
.cs-selecting .card *,
|
||||
.cs-selecting .template-card * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Suppress hover lift during selection */
|
||||
.cs-selecting .card:hover,
|
||||
.cs-selecting .template-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Corner checkmark on selected cards — replaces the per-card checkbox
|
||||
that used to be injected at the top of the card body. Sits in the
|
||||
top-right corner where the kebab is otherwise visible (kebab is
|
||||
pointer-events:none in this mode, so the indicator can overlay it). */
|
||||
.cs-selecting .card-selected::before,
|
||||
.cs-selecting .template-card.card-selected::before {
|
||||
/* override the channel stripe pulse by restoring full opacity */
|
||||
opacity: 1;
|
||||
}
|
||||
.cs-selecting .card-selected,
|
||||
.cs-selecting .template-card.card-selected {
|
||||
position: relative;
|
||||
}
|
||||
.cs-selecting .card-selected > .mod-bulk-tick,
|
||||
.cs-selecting .template-card.card-selected > .mod-bulk-tick {
|
||||
display: flex;
|
||||
}
|
||||
/* The tick itself — injected once per card via JS so it picks up the
|
||||
.card-selected toggle naturally. Hidden until the card is selected. */
|
||||
.mod-bulk-tick {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast, #fff);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--card-bg, var(--lux-bg-1)),
|
||||
0 0 14px color-mix(in srgb, var(--primary-color) 50%, transparent);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mod-bulk-tick svg {
|
||||
display: block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke-width: 2.6;
|
||||
}
|
||||
|
||||
.cs-selecting .card-selected > .mod-bulk-tick,
|
||||
.cs-selecting .template-card.card-selected > .mod-bulk-tick {
|
||||
animation: bulkTickPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes bulkTickPop {
|
||||
0% { transform: scale(0.4); opacity: 0; }
|
||||
60% { transform: scale(1.12); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cs-selecting .card-selected > .mod-bulk-tick,
|
||||
.cs-selecting .template-card.card-selected > .mod-bulk-tick {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bulk toolbar ──────────────────────────────────────────── */
|
||||
|
||||
#bulk-toolbar {
|
||||
--bulk-ch: var(--primary-color);
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(calc(100% + 30px));
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 8px 16px;
|
||||
transform: translateX(-50%) translateY(calc(100% + 40px));
|
||||
background: linear-gradient(180deg,
|
||||
var(--lux-bg-1, var(--card-bg)) 0%,
|
||||
var(--lux-bg-2, var(--card-bg)) 100%);
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
|
||||
border-radius: var(--lux-r-lg, var(--radius-md, 10px));
|
||||
padding: 8px 10px 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
z-index: var(--z-bulk-toolbar);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.25s ease;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02),
|
||||
0 12px 36px rgba(0, 0, 0, 0.5),
|
||||
0 4px 16px var(--shadow-color, rgba(0, 0, 0, 0.4));
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#bulk-toolbar.visible {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.bulk-select-all-wrap {
|
||||
/* Top accent stripe — same channel-glow language as modals */
|
||||
#bulk-toolbar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 1.5px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
var(--bulk-ch) 20%,
|
||||
var(--bulk-ch) 80%,
|
||||
transparent 100%);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--bulk-ch) 55%, transparent);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Pick group (Select all / Deselect all) */
|
||||
.bulk-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--lux-bg-0, var(--bg-color));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-sm, 4px);
|
||||
}
|
||||
|
||||
.bulk-select-all-cb {
|
||||
.bulk-pick-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
cursor: pointer;
|
||||
transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.bulk-pick-btn .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bulk-pick-btn:hover:not(:disabled) {
|
||||
color: var(--bulk-ch);
|
||||
background: color-mix(in srgb, var(--bulk-ch) 14%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bulk-ch) 35%, transparent),
|
||||
0 0 12px color-mix(in srgb, var(--bulk-ch) 30%, transparent);
|
||||
}
|
||||
|
||||
.bulk-pick-btn:disabled,
|
||||
.bulk-pick-btn[aria-disabled="true"] {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 80px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
min-width: 90px;
|
||||
padding: 0 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bulk-count-total {
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
font-size: 0.95em;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-left: 8px;
|
||||
margin-left: 4px;
|
||||
border-left: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
}
|
||||
|
||||
.bulk-action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: box-shadow 0.18s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.bulk-action-btn .icon {
|
||||
@@ -1615,18 +1778,574 @@ ul.section-tip li {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.bulk-action-btn:hover:not(:disabled) {
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bulk-action-btn.btn-danger:hover:not(:disabled) {
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--danger-color, #ef4444) 40%, transparent);
|
||||
}
|
||||
|
||||
.bulk-action-btn:disabled,
|
||||
.bulk-action-btn[aria-disabled="true"] {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bulk-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: transparent;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
border-radius: var(--lux-r-sm, 4px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 2px;
|
||||
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.bulk-close .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.bulk-close:hover {
|
||||
color: var(--text-color);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
border-color: color-mix(in srgb, var(--bulk-ch) 50%, var(--lux-line, var(--border-color)));
|
||||
background: color-mix(in srgb, var(--bulk-ch) 8%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#bulk-toolbar {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.bulk-action-btn:hover:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
Mod-cards — entity cards that adopt the dashboard's `.mod-*` markup
|
||||
════════════════════════════════════════════════════════════════════
|
||||
The `.mod-*` child selectors (mod-head, mod-leds, mod-metrics,
|
||||
mod-foot, mod-patch, mod-btn, etc.) live in dashboard.css and apply
|
||||
regardless of host class. The rules below adapt the .card /
|
||||
.template-card hosts to layout the modular children correctly (gap,
|
||||
padding, suppress legacy decorations) and add the kebab menu styles
|
||||
that are unique to entity cards.
|
||||
════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.card.mod-card,
|
||||
.template-card.mod-card {
|
||||
/* Vertical flex stack matches .dashboard-target:has(.mod-head) */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px 18px 14px 22px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Foot pinned to the card bottom — entity cards have variable body
|
||||
content (chips/metrics/fader may all be absent), so without this the
|
||||
foot floats in the middle when the grid forces a tall card. */
|
||||
.card.mod-card .mod-foot,
|
||||
.template-card.mod-card .mod-foot {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Suppress legacy decorations when modular children are present */
|
||||
.card.mod-card .card-header,
|
||||
.card.mod-card .card-actions,
|
||||
.card.mod-card .card-top-actions,
|
||||
.template-card.mod-card .template-card-header,
|
||||
.template-card.mod-card .template-card-actions,
|
||||
.template-card.mod-card .card-top-actions { display: none; }
|
||||
|
||||
/* Idle silkscreen corner bracket — drop on mod-cards with a kebab.
|
||||
The kebab visually replaces the bracket as the top-right anchor. */
|
||||
.card.mod-card:has(.mod-menu-wrap):not(.is-running)::after,
|
||||
.template-card.mod-card:has(.mod-menu-wrap):not(.is-running)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Running state on mod-cards — same intensified stripe + bottom signal-
|
||||
flow used by .dashboard-target.is-running. Two class names keep
|
||||
markup interchangeable: callers can use `is-running` (dashboard
|
||||
convention) or `card-running` (legacy entity-card convention). */
|
||||
.card.mod-card.is-running,
|
||||
.card.mod-card.card-running,
|
||||
.template-card.mod-card.is-running,
|
||||
.template-card.mod-card.card-running {
|
||||
border-color: color-mix(in srgb, var(--ch) 32%, var(--lux-line, var(--border-color)));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ch) 18%, transparent),
|
||||
0 6px 20px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.card.mod-card.is-running::before,
|
||||
.card.mod-card.card-running::before,
|
||||
.template-card.mod-card.is-running::before,
|
||||
.template-card.mod-card.card-running::before {
|
||||
opacity: 1;
|
||||
width: 4px;
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent),
|
||||
0 0 4px color-mix(in srgb, var(--ch) 90%, transparent);
|
||||
}
|
||||
|
||||
/* Bottom signal-flow strip (running indicator) */
|
||||
.card.mod-card.is-running::after,
|
||||
.card.mod-card.card-running::after,
|
||||
.template-card.mod-card.is-running::after,
|
||||
.template-card.mod-card.card-running::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: auto; right: auto;
|
||||
left: 4px; bottom: 0;
|
||||
width: calc(100% - 4px); height: 2px;
|
||||
border: none;
|
||||
opacity: 0.7;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--ch) 85%, transparent) 50%,
|
||||
transparent 100%);
|
||||
background-size: 30% 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: signalFlow 2.4s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card.mod-card.is-running::after,
|
||||
.card.mod-card.card-running::after,
|
||||
.template-card.mod-card.is-running::after,
|
||||
.template-card.mod-card.card-running::after {
|
||||
animation: none;
|
||||
background-position: 50% 0;
|
||||
background-size: 60% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Color picker dot inside .mod-badge ─────────────────────────── */
|
||||
|
||||
.mod-badge { padding-left: 4px; }
|
||||
|
||||
.mod-badge__color-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mod-badge__color {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--ch);
|
||||
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 60%, var(--lux-line-bold, var(--border-color)));
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--ch) 40%, transparent);
|
||||
}
|
||||
.mod-badge__color:hover,
|
||||
.mod-badge__color:focus-visible {
|
||||
transform: scale(1.25);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ch) 25%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
/* User picked a personal hue — fill with --user-color but keep the
|
||||
channel-tinted ring */
|
||||
.mod-badge__color[data-custom] {
|
||||
background: var(--user-color);
|
||||
}
|
||||
|
||||
/* ── Overflow menu (kebab) ──────────────────────────────────────── */
|
||||
|
||||
.mod-menu-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
/* Match the .mod-leds bezel height (~22px) so the kebab and the LED
|
||||
cluster sit on the same visual baseline at the top of the head row.
|
||||
Without this, the 26px kebab dropped 4px below the 22px bezel and
|
||||
read as misaligned. */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.mod-menu-btn {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 3px;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, opacity 0.15s;
|
||||
opacity: 0.45;
|
||||
padding: 0;
|
||||
}
|
||||
.card.mod-card:hover .mod-menu-btn,
|
||||
.template-card.mod-card:hover .mod-menu-btn,
|
||||
.dashboard-target:hover .mod-menu-btn,
|
||||
.mod-menu-wrap.is-open .mod-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.mod-menu-btn:hover,
|
||||
.mod-menu-wrap.is-open .mod-menu-btn {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: var(--lux-bg-3, var(--border-color));
|
||||
}
|
||||
.mod-menu-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 50%, transparent);
|
||||
}
|
||||
.mod-menu-btn .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
/* The kebab path uses fill (filled circles) — disable stroke */
|
||||
stroke: none;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* The dropdown — `position: fixed` with JS-computed coordinates.
|
||||
* mod-menu.ts portals the element to <body> on open so that a
|
||||
* transformed ancestor (e.g. card hover translate) doesn't trap the
|
||||
* fixed positioning, and the card's `overflow: hidden` doesn't clip
|
||||
* the dropdown. Display toggles on `.mod-menu.is-open` (set whether
|
||||
* the menu is in the wrap or portaled to body). */
|
||||
.mod-menu {
|
||||
position: fixed;
|
||||
z-index: var(--z-dropdown, 200);
|
||||
min-width: 172px;
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
|
||||
border-radius: var(--lux-r-sm, 4px);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
padding: 4px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
transform-origin: top right;
|
||||
animation: modMenuIn 0.12s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.mod-menu.is-open { display: flex; }
|
||||
|
||||
@keyframes modMenuIn {
|
||||
from { opacity: 0; transform: scale(0.96) translateY(-4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.mod-menu__item {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 2px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
.mod-menu__item:hover,
|
||||
.mod-menu__item:focus-visible {
|
||||
background: var(--lux-bg-3, var(--border-color));
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
outline: none;
|
||||
}
|
||||
.mod-menu__item .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
}
|
||||
.mod-menu__item:hover .icon,
|
||||
.mod-menu__item:focus-visible .icon {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
.mod-menu__sep {
|
||||
height: 1px;
|
||||
margin: 4px 6px;
|
||||
background: var(--lux-line, var(--border-color));
|
||||
}
|
||||
|
||||
.mod-menu__item--danger {
|
||||
color: var(--ch-coral, var(--danger-color));
|
||||
}
|
||||
.mod-menu__item--danger .icon {
|
||||
color: var(--ch-coral, var(--danger-color));
|
||||
opacity: 0.85;
|
||||
}
|
||||
.mod-menu__item--danger:hover,
|
||||
.mod-menu__item--danger:focus-visible {
|
||||
background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 12%, transparent);
|
||||
color: var(--ch-coral, var(--danger-color));
|
||||
}
|
||||
.mod-menu__item--danger:hover .icon,
|
||||
.mod-menu__item--danger:focus-visible .icon {
|
||||
color: var(--ch-coral, var(--danger-color));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Property chips (replaces parallel .card-meta / .stream-card-prop
|
||||
systems on mod-cards). Reused by the legacy .stream-card-prop
|
||||
class so cards can mix-and-match during migration. ─────────── */
|
||||
|
||||
.mod-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mod-card .chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
background: transparent;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
cursor: default;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mod-card .chip .icon {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex-shrink: 0;
|
||||
color: var(--ch);
|
||||
}
|
||||
.mod-card .chip--link { cursor: pointer; }
|
||||
.mod-card .chip--link:hover {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
border-color: color-mix(in srgb, var(--ch) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--ch) 12%, transparent);
|
||||
}
|
||||
.mod-card .chip--tag {
|
||||
color: var(--ch);
|
||||
border-color: color-mix(in srgb, var(--ch) 22%, transparent);
|
||||
background: color-mix(in srgb, var(--ch) 10%, transparent);
|
||||
}
|
||||
.mod-card .chip--err {
|
||||
color: var(--ch-coral, var(--danger-color));
|
||||
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Inline chip variant — used by the pipeline strip on running target
|
||||
cards. A bigger numeric (display-font-ish) sits beside a small mono
|
||||
caps unit, so a chip can carry "127K · frames" without looking like
|
||||
a label-shaped pill. The data values inside still update via
|
||||
_patchTargetMetrics. */
|
||||
.mod-card .chip--inline {
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.mod-card .chip--inline > span {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.mod-card .chip--inline > small {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
}
|
||||
|
||||
/* ── Pipeline strip — replaces the old target-metrics-collapse. A
|
||||
thin always-visible segmented timing bar plus a tight chip row
|
||||
below it (timing total / frames / keepalive). The bar is the
|
||||
diagnostic; the chips are the counters. */
|
||||
.mod-card .target-pipeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.mod-card .target-pipeline .timing-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--lux-bg-0, var(--bg-color));
|
||||
box-shadow: inset 0 0 0 1px var(--lux-line, var(--border-color));
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
.mod-card .target-pipeline .timing-seg {
|
||||
transition: flex 0.3s ease;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.mod-card .target-pipeline .timing-seg:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.mod-card .target-pipeline-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Brightness fader (mod-card variant) ────────────────────────── */
|
||||
|
||||
.mod-fader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
background: var(--lux-bg-0, var(--bg-color));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-sm, 4px);
|
||||
}
|
||||
.mod-fader__k {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
min-width: 42px;
|
||||
}
|
||||
.mod-fader__lane {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.mod-fader__track {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 5px;
|
||||
border-radius: 99px;
|
||||
background: color-mix(in srgb, var(--ch) 12%, var(--lux-bg-3, var(--border-color)));
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-theme="light"] .mod-fader__track {
|
||||
box-shadow: inset 0 1px 2px rgba(15, 20, 25, 0.08);
|
||||
}
|
||||
.mod-fader__fill {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--ch) 50%, transparent),
|
||||
var(--ch));
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--ch) 60%, transparent);
|
||||
}
|
||||
/* The slider input overlays the visible track exactly — same flex slot,
|
||||
so its hit-zone aligns to the fill regardless of label/value width. */
|
||||
.mod-fader__slider {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 18px;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
.mod-fader__v {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
min-width: 34px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Preview surface (gradient strip / asset thumb / LED preview) ── */
|
||||
|
||||
.mod-preview {
|
||||
border-radius: var(--lux-r-sm, 4px);
|
||||
box-shadow: inset 0 0 0 1px var(--lux-line, var(--border-color));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.mod-preview canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Corner tag overlay on a preview surface (e.g. "BUILT-IN" on built-in
|
||||
gradient strips). Sits in the top-right with a backdrop-blurred dark
|
||||
pill so it stays legible on any gradient — light, dark, or mid-tone. */
|
||||
.mod-preview__tag {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
padding: 2px 7px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* ── Description text ──────────────────────────────────────────── */
|
||||
|
||||
.mod-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ── Mod-card specific tweaks for chips inside cards ─────────────
|
||||
The flex order in .mod-foot is: .mod-patch (margin-right:auto) on
|
||||
the left, primary action(s) on the right. Multiple .chip rows above
|
||||
the foot inherit normal flex-wrap behavior from .mod-chips. */
|
||||
|
||||
/* Mobile — reduce padding on mod-cards */
|
||||
@media (max-width: 768px) {
|
||||
.card.mod-card,
|
||||
.template-card.mod-card {
|
||||
padding: 14px 14px 12px 18px;
|
||||
gap: 12px;
|
||||
}
|
||||
.mod-menu {
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,19 @@
|
||||
background: var(--lux-bg-3, var(--border-color));
|
||||
}
|
||||
|
||||
/* Transparent hairline variant — used for low-emphasis actions like
|
||||
"Revert" inside the per-section save bar. */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--lux-ink-dim, var(--text-color));
|
||||
border-color: var(--lux-line, var(--border-color));
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
border-color: var(--lux-line-bold, var(--border-color));
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
min-width: auto;
|
||||
padding: 7px 10px;
|
||||
@@ -434,21 +447,52 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
.toast {
|
||||
--toast-ch: var(--primary-color);
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
padding: 16px 24px;
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
padding: 14px 22px;
|
||||
border-radius: var(--lux-r-lg, var(--radius-md, 10px));
|
||||
color: var(--lux-ink, #fff);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
font-size: 14.5px;
|
||||
letter-spacing: 0.01em;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: var(--z-toast);
|
||||
box-shadow: 0 4px 20px var(--shadow-color);
|
||||
background: linear-gradient(180deg,
|
||||
var(--lux-bg-1, var(--card-bg)) 0%,
|
||||
var(--lux-bg-2, var(--card-bg)) 100%);
|
||||
border: var(--lux-hairline, 1px) solid color-mix(in srgb,
|
||||
var(--toast-ch) 35%, var(--lux-line-bold, var(--border-color)));
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02),
|
||||
0 14px 40px rgba(0, 0, 0, 0.5),
|
||||
0 0 26px color-mix(in srgb, var(--toast-ch) 22%, transparent);
|
||||
min-width: 300px;
|
||||
max-width: min(560px, calc(100vw - 32px));
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Top accent stripe — matches modals/bulk-toolbar so the channel language
|
||||
is consistent across every floating surface. */
|
||||
.toast::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 1.5px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
var(--toast-ch) 20%,
|
||||
var(--toast-ch) 80%,
|
||||
transparent 100%);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--toast-ch) 55%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
@@ -458,23 +502,15 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
@keyframes toastBounceIn {
|
||||
0% { transform: translateX(-50%) translateY(60px); opacity: 0; }
|
||||
50% { transform: translateX(-50%) translateY(-4px); opacity: 1; }
|
||||
70% { transform: translateX(-50%) translateY(2px); }
|
||||
0% { transform: translateX(-50%) translateY(60px); opacity: 0; }
|
||||
50% { transform: translateX(-50%) translateY(-4px); opacity: 1; }
|
||||
70% { transform: translateX(-50%) translateY(2px); }
|
||||
100% { transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background: var(--info-color);
|
||||
}
|
||||
.toast.success { --toast-ch: var(--primary-color); }
|
||||
.toast.error { --toast-ch: var(--danger-color); }
|
||||
.toast.info { --toast-ch: var(--info-color); }
|
||||
|
||||
/* Toast with undo action */
|
||||
.toast-with-action {
|
||||
@@ -488,31 +524,38 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
.toast-undo-btn {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--toast-ch) 18%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--toast-ch) 50%, transparent);
|
||||
color: var(--lux-ink, #fff);
|
||||
padding: 5px 14px;
|
||||
border-radius: var(--lux-r-sm, var(--radius-sm));
|
||||
font-weight: var(--weight-semibold, 600);
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast, 0.15s);
|
||||
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-undo-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
background: color-mix(in srgb, var(--toast-ch) 32%, transparent);
|
||||
border-color: var(--toast-ch);
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--toast-ch) 35%, transparent);
|
||||
}
|
||||
|
||||
.toast-timer {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 0 0 var(--lux-r-lg, var(--radius-md)) var(--lux-r-lg, var(--radius-md));
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--toast-ch) 70%, transparent) 0%,
|
||||
var(--toast-ch) 50%,
|
||||
color-mix(in srgb, var(--toast-ch) 70%, transparent) 100%);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--toast-ch) 60%, transparent);
|
||||
transform-origin: left;
|
||||
animation: toastTimer var(--toast-duration, 5s) linear forwards;
|
||||
}
|
||||
@@ -749,6 +792,15 @@ textarea:focus-visible {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Hide empty icon/label slots so flex gaps don't reserve unused space.
|
||||
Used by minimal items like the locale picker (2-letter code only). */
|
||||
.icon-select-trigger-icon:empty,
|
||||
.icon-select-trigger-label:empty,
|
||||
.icon-select-cell-icon:empty,
|
||||
.icon-select-cell-label:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon-select-popup {
|
||||
position: fixed;
|
||||
z-index: var(--z-lightbox);
|
||||
@@ -1064,6 +1116,17 @@ textarea:focus-visible {
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
/* Section header rows in EntityPalette (non-selectable, used for grouping). */
|
||||
.entity-palette-header {
|
||||
padding: 6px 14px 2px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Entity Select trigger (replaces <select>) */
|
||||
.entity-select-trigger {
|
||||
display: flex;
|
||||
@@ -1107,6 +1170,116 @@ textarea:focus-visible {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Timezone picker (Settings modal) ──────────────────────
|
||||
Refined "instrument readout" trigger that matches the lux
|
||||
sectional design language used in the rest of the modal:
|
||||
- hairline border, deeper bg, monospaced offset chip
|
||||
- region icon stenciled in --ch-cyan to echo section dots
|
||||
- subtle hover glow on the channel hue, not the primary green */
|
||||
|
||||
.tz-picker-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.tz-picker-wrap .entity-select-trigger {
|
||||
--tz-ch: var(--ch-cyan, var(--primary-color));
|
||||
background: var(--lux-bg-1, var(--bg-color));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-md, 6px);
|
||||
padding: 10px 12px;
|
||||
gap: 12px;
|
||||
font-feature-settings: "ss01", "tnum";
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
transition:
|
||||
border-color 180ms var(--ease-out, ease-out),
|
||||
background 180ms var(--ease-out, ease-out),
|
||||
box-shadow 220ms var(--ease-out, ease-out);
|
||||
}
|
||||
|
||||
.tz-picker-wrap .entity-select-trigger::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--tz-ch) 80%, transparent),
|
||||
color-mix(in srgb, var(--tz-ch) 0%, transparent) 70%
|
||||
);
|
||||
border-radius: var(--lux-r-md, 6px) 0 0 var(--lux-r-md, 6px);
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
transition: opacity 220ms var(--ease-out, ease-out);
|
||||
}
|
||||
|
||||
.tz-picker-wrap .entity-select-trigger:hover {
|
||||
border-color: color-mix(in srgb, var(--tz-ch) 55%, var(--lux-line-bold, var(--border-color)));
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tz-ch) 18%, transparent),
|
||||
0 8px 24px -10px color-mix(in srgb, var(--tz-ch) 30%, transparent);
|
||||
}
|
||||
|
||||
.tz-picker-wrap .entity-select-trigger:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tz-picker-wrap .entity-select-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--tz-ch);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--tz-ch) 25%, transparent);
|
||||
}
|
||||
|
||||
.tz-picker-wrap .es-trigger-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--tz-ch) 12%, transparent);
|
||||
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--tz-ch) 25%, transparent);
|
||||
color: var(--tz-ch);
|
||||
}
|
||||
|
||||
.tz-picker-wrap .es-trigger-icon .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.tz-picker-wrap .es-trigger-label {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
/* Render the offset segment after the city in monospaced "panel-meter" type */
|
||||
.tz-picker-wrap .es-trigger-label {
|
||||
/* Allow ellipsis to keep the trigger compact in narrow layouts. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tz-picker-wrap .es-trigger-arrow {
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
opacity: 0.7;
|
||||
transform: translateY(-1px);
|
||||
transition: transform 220ms var(--ease-out, ease-out), color 180ms ease;
|
||||
}
|
||||
|
||||
.tz-picker-wrap:hover .es-trigger-arrow {
|
||||
color: var(--tz-ch);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* When the value is the system default (empty string), dim the label slightly
|
||||
so users can see it is in the "no override" state at a glance. */
|
||||
.tz-picker-wrap .entity-select-trigger:has(.es-trigger-none) {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* ── Scroll-to-top button ── */
|
||||
|
||||
.scroll-to-top {
|
||||
|
||||
@@ -204,9 +204,17 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.dash-cust-row.is-drop-target {
|
||||
border-color: var(--ch-signal, var(--primary-color));
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent);
|
||||
.dash-cust-row.is-drop-target-before,
|
||||
.dash-cust-row.is-drop-target-after {
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 6%, transparent);
|
||||
}
|
||||
|
||||
.dash-cust-row.is-drop-target-before {
|
||||
box-shadow: 0 -2px 0 0 var(--ch-signal, var(--primary-color));
|
||||
}
|
||||
|
||||
.dash-cust-row.is-drop-target-after {
|
||||
box-shadow: 0 2px 0 0 var(--ch-signal, var(--primary-color));
|
||||
}
|
||||
|
||||
.dash-cust-row-fixed {
|
||||
|
||||
@@ -71,8 +71,8 @@
|
||||
|
||||
.dashboard-subsection .dashboard-section-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min, 380px), 100%), 1fr));
|
||||
gap: var(--card-grid-gap, 14px);
|
||||
}
|
||||
|
||||
.dashboard-subsection .dashboard-section-content .dashboard-target {
|
||||
@@ -286,6 +286,42 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Device URL hyperlink inside the meta line.
|
||||
Picks up the card's channel accent (--ch) so it ties into the card's
|
||||
color identity instead of using the generic browser-blue link style. */
|
||||
.mod-meta__link {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
color: var(--ch, var(--primary-text-color));
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted color-mix(in srgb, var(--ch, var(--primary-color)) 55%, transparent);
|
||||
padding-bottom: 1px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, text-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.mod-meta__link:hover,
|
||||
.mod-meta__link:focus-visible {
|
||||
color: var(--ch, var(--primary-color));
|
||||
border-bottom-style: solid;
|
||||
border-bottom-color: currentColor;
|
||||
text-shadow: 0 0 8px color-mix(in srgb, var(--ch, var(--primary-color)) 35%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mod-meta__link .icon {
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
transform: translateY(0.12em);
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.mod-meta__link:hover .icon,
|
||||
.mod-meta__link:focus-visible .icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mod-leds {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -297,6 +333,143 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Custom card icon plate ──────────────────────────────────────
|
||||
A 44x44 instrument-panel face plate at the leading edge of the
|
||||
head row. Channel-tinted; clickable to open the icon picker.
|
||||
Renders only when ModHeadOpts.iconHtml is supplied.
|
||||
|
||||
The plate sits at the top-left of the head row at its own fixed
|
||||
size. We deliberately do NOT set align-items:stretch on the head —
|
||||
that would force sibling slots (LED bezel, kebab) to inherit the
|
||||
plate's height. Instead each slot keeps its natural compact size.
|
||||
*/
|
||||
|
||||
.mod-icon {
|
||||
--plate-size: 44px;
|
||||
flex: 0 0 var(--plate-size);
|
||||
width: var(--plate-size);
|
||||
height: var(--plate-size);
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--ch) 10%, var(--lux-bg-0, var(--bg-color))) 0%,
|
||||
var(--lux-bg-0, var(--bg-color)) 100%);
|
||||
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 28%, var(--lux-line, var(--border-color)));
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
color: var(--ch);
|
||||
cursor: default;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ch) 14%, transparent),
|
||||
inset 0 -8px 14px color-mix(in srgb, var(--ch) 6%, transparent);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
font: inherit;
|
||||
line-height: 0;
|
||||
}
|
||||
button.mod-icon { cursor: pointer; }
|
||||
|
||||
.mod-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--ch) 55%, var(--lux-line, var(--border-color)));
|
||||
border-right: 1px solid color-mix(in srgb, var(--ch) 55%, var(--lux-line, var(--border-color)));
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mod-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(180deg,
|
||||
rgba(255, 255, 255, 0.02) 0 1px,
|
||||
transparent 1px 3px);
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.mod-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
z-index: 1;
|
||||
transition: transform 0.25s ease;
|
||||
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--ch) 30%, transparent));
|
||||
}
|
||||
|
||||
button.mod-icon:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--ch) 60%, var(--lux-line, var(--border-color)));
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ch) 24%, transparent),
|
||||
inset 0 -10px 18px color-mix(in srgb, var(--ch) 10%, transparent),
|
||||
0 0 0 3px color-mix(in srgb, var(--ch) 18%, transparent),
|
||||
0 4px 12px color-mix(in srgb, var(--ch) 22%, transparent);
|
||||
}
|
||||
button.mod-icon:hover svg { transform: scale(1.06); }
|
||||
button.mod-icon:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 60%, transparent);
|
||||
}
|
||||
|
||||
/* Empty / placeholder plate — kept visible so the slot is discoverable.
|
||||
Styled as a dashed outline with a quiet "+" glyph; tints to --ch on
|
||||
hover so the user sees it light up just like a populated plate. */
|
||||
.mod-icon.is-empty {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
border-color: var(--lux-line-bold, var(--border-color));
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
box-shadow: none;
|
||||
}
|
||||
.mod-icon.is-empty::before,
|
||||
.mod-icon.is-empty::after { display: none; }
|
||||
.mod-icon.is-empty svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
filter: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
button.mod-icon.is-empty:hover {
|
||||
transform: none;
|
||||
border-color: color-mix(in srgb, var(--ch) 60%, var(--lux-line-bold, var(--border-color)));
|
||||
color: var(--ch);
|
||||
background: color-mix(in srgb, var(--ch) 5%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 14%, transparent);
|
||||
}
|
||||
button.mod-icon.is-empty:hover svg { transform: none; opacity: 1; }
|
||||
.is-running .mod-icon.is-empty,
|
||||
.card-running .mod-icon.is-empty { animation: none; }
|
||||
|
||||
/* Running cards: the plate breathes with the live indicator. */
|
||||
.is-running .mod-icon,
|
||||
.card-running .mod-icon {
|
||||
animation: modIconPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes modIconPulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ch) 14%, transparent),
|
||||
inset 0 -8px 14px color-mix(in srgb, var(--ch) 6%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ch) 22%, transparent),
|
||||
inset 0 -8px 18px color-mix(in srgb, var(--ch) 12%, transparent),
|
||||
0 0 14px color-mix(in srgb, var(--ch) 28%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.mod-leds .led {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -404,6 +577,34 @@
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
/* Text-stack variant — used for short identifier values that don't render
|
||||
well in the heavy display font (e.g. SK6812 + RGBW). The primary line
|
||||
sits in the same slot as `.v`, and an optional `.v-sub` element below
|
||||
carries a secondary line in the small mono caps style. */
|
||||
.mod-metric--text-stack .v {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.15;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.mod-metric--text-stack .v-sub {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mod-metric .v .dashboard-fps-target {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.6rem;
|
||||
@@ -757,8 +958,8 @@
|
||||
.dashboard-integrations-grid,
|
||||
.dashboard-autostart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min-narrow, 320px), 100%), 1fr));
|
||||
gap: var(--card-grid-gap-narrow, 12px);
|
||||
}
|
||||
|
||||
/* Legacy row-style overrides kept for any card that still lacks .mod-head */
|
||||
@@ -1139,6 +1340,12 @@
|
||||
padding: 0 18px 14px;
|
||||
cursor: crosshair;
|
||||
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--perf-accent) 45%, transparent));
|
||||
/* Clip the scroll-out animation: when a new sample arrives the SVG
|
||||
is snap-translated +1 step right then eased back to 0, so the
|
||||
previous sample's frame remains visually anchored while old
|
||||
samples slide off the left. Without overflow:hidden the temporary
|
||||
overshoot would peek into the padding. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.perf-chart-spark .perf-chart-svg {
|
||||
@@ -1149,6 +1356,18 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
/* Promote to its own compositor layer so the scroll animation runs
|
||||
on the GPU — the path strings underneath don't repaint, only the
|
||||
layer transform updates per frame. */
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Honor the global animations preference. "reduced" keeps the scroll
|
||||
but cuts duration so motion is brief; "off" pins the SVG so each
|
||||
tick is a hard cut. */
|
||||
[data-layout-anim="off"] .perf-chart-spark .perf-chart-svg {
|
||||
transition: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.perf-chart-unavailable {
|
||||
@@ -1262,6 +1481,69 @@
|
||||
— the list owns the rest of the cell height. */
|
||||
.perf-patches-cell .perf-chart-spark { display: none; }
|
||||
|
||||
/* Errors cell — value is muted at 0 (healthy state, no alarm) and
|
||||
shifts to the cell's coral accent the moment the rate or cumulative
|
||||
count goes non-zero. The accent stripe + value tint give a passive
|
||||
"is anything wrong?" indicator without flashing or animation. */
|
||||
.perf-errors-cell .perf-chart-value {
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
opacity: 0.65;
|
||||
}
|
||||
.perf-errors-cell.has-errors .perf-chart-value {
|
||||
color: var(--perf-accent, var(--ch-coral, var(--danger-color)));
|
||||
opacity: 1;
|
||||
}
|
||||
.perf-errors-cell.has-errors .perf-chart-subtitle {
|
||||
color: var(--perf-accent, var(--ch-coral, var(--danger-color)));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Empty-state hint shown when no patches are running. A pulsing accent
|
||||
dot keeps the card visually alive even when idle, and the small caps
|
||||
text reads as a status line ("Ready to launch") rather than a stale
|
||||
empty list. */
|
||||
.perf-patches-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
opacity: 0.85;
|
||||
}
|
||||
.perf-patches-empty-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--perf-accent, var(--ch-magenta, var(--primary-color)));
|
||||
box-shadow: 0 0 6px currentColor;
|
||||
color: var(--perf-accent, var(--ch-magenta, var(--primary-color)));
|
||||
flex-shrink: 0;
|
||||
animation: perfPatchesIdlePulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
.perf-patches-empty-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@keyframes perfPatchesIdlePulse {
|
||||
0%, 100% { opacity: 0.45; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
/* Honor the global "reduced/off" animations preference set on the
|
||||
dashboard root — the pulse vanishes when the user wants stillness. */
|
||||
[data-layout-anim="off"] .perf-patches-empty-dot,
|
||||
[data-layout-anim="reduced"] .perf-patches-empty-dot {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Devices cell — online/total count + dot strip per device ── */
|
||||
.perf-devices-cell {
|
||||
--perf-accent: var(--ch-signal, var(--primary-color));
|
||||
@@ -1323,7 +1605,7 @@
|
||||
color: var(--perf-accent);
|
||||
}
|
||||
|
||||
.perf-chart-card[data-metric="fps"] .perf-fps-unit {
|
||||
.perf-chart-card .perf-fps-unit {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.3em;
|
||||
font-weight: 500;
|
||||
@@ -1333,6 +1615,12 @@
|
||||
margin-left: 6px;
|
||||
align-self: center;
|
||||
}
|
||||
/* Errors cell — when the rate is non-zero, the unit takes the same coral
|
||||
tint as the value so they read as a single composite reading. */
|
||||
.perf-errors-cell.has-errors .perf-fps-unit {
|
||||
color: var(--perf-accent, var(--ch-coral, var(--danger-color)));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Target-FPS ceiling suffix — "/ 120" next to the big live number, sized
|
||||
down + muted so the live value remains the primary reading. Matches
|
||||
|
||||
@@ -430,7 +430,7 @@ html:has(#tab-graph.active) {
|
||||
}
|
||||
|
||||
.graph-node-body {
|
||||
fill: var(--card-bg);
|
||||
fill: var(--lux-bg-1, var(--card-bg));
|
||||
stroke: var(--lux-line, var(--border-color));
|
||||
stroke-width: 1;
|
||||
rx: 6;
|
||||
@@ -723,7 +723,7 @@ html:has(#tab-graph.active) {
|
||||
}
|
||||
|
||||
.graph-node-overlay-bg {
|
||||
fill: var(--card-bg);
|
||||
fill: var(--lux-bg-1, var(--card-bg));
|
||||
stroke: var(--border-color);
|
||||
stroke-width: 1;
|
||||
rx: 6;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user