Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ef6ac1317 | |||
| 0980cf4dde | |||
| fdac26b9d9 | |||
| 816a27db73 | |||
| 797b806972 | |||
| 9d4a534ec6 | |||
| 51eebf21d5 | |||
| 9067db2639 | |||
| 233b463ac3 | |||
| de13f44f24 | |||
| 1c9acc5afb | |||
| a56569b02f | |||
| ccf4406349 | |||
| 8aa3a323d6 | |||
| 8e109f32b9 | |||
| 033c1f6a92 |
@@ -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.
|
||||
|
||||
+43
-25
@@ -1,27 +1,42 @@
|
||||
## v0.5.0 (2026-04-25)
|
||||
## v0.6.0 (2026-05-01)
|
||||
|
||||
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.
|
||||
This release adds **device-event notifications** (snack + Web Notifications), a **daylight/timezone-aware streaming pipeline** with a new camera engine, a **redesigned Targets surface** built on the dashboard's mod-card system, a **tighter LED hot path** with allocation-free per-frame work, and a **revamped Release Notes overlay** with clickable asset downloads. Plus a wide pass of modal, toolbar, and settings polish across the WebUI.
|
||||
|
||||
### 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))
|
||||
- **Device event notifications** — configurable per-event channel matrix (none / snack / OS / both) for target online/offline, new WLED/serial discovery, and devices going missing. Backed by a long-running mDNS browser + 10 s serial poller, a startup-grace / flap-debounce / bulk-coalesce pipeline, and a new Notifications tab in Settings (en/ru/zh). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
|
||||
- **Daylight + timezone streaming** — new `daylight_settings` module and `daylight-tz` frontend helper expand the daylight stream's behavior; capture path additions land alongside a new **camera engine** test suite. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
|
||||
- **Targets cards migrated to the mod-card system** — LED targets and HA Light targets now share the dashboard's instrument-readout vocabulary (mod-head / mod-leds / mod-metrics / mod-foot, kebab menu, badges, chips, patch indicator). LED preview, FPS sparkline, and pipeline metrics preserved via an `extraHtml` escape hatch. ([233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463))
|
||||
- **Target pipeline as a compact strip + chip row** — drops the legacy "Pipeline details" collapsible block; an always-visible 4 px segmented timing bar (extract / map / smooth / send for video, read / fft / render / send for audio) sits above an inline chip row showing total ms / frames / keepalives, animating smoothly between samples. ([51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2))
|
||||
- **Targets metrics aligned with the dashboard** — FPS sparkline now lives inside the FPS cell, Uptime gets a clock icon, Errors gets ok/warning by count, FPS readout adopts the dashboard `current/target avg N.N` shape, and the grid sizes so values like `1m 43s` no longer truncate at typical desktop widths. ([9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2))
|
||||
- **Release Notes overlay v2** — new masthead with display-font title, tag/published/pre-release chip strip, and close/external actions; markdown body fuzzy-matches `<code>` filenames to release assets and renders clickable download links with per-asset descriptions (Windows installer/portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android apk/aab, iOS ipa). Checksum/signature side-files are hidden. ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
|
||||
- **Tutorials expansion** — sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks let tours open/close the dashboard customize panel and resolve targets behind sub-tabs; new steps for integrations, dashboard customize panel (presets / global / sections / perf cells), targets, scenes, and sync-clocks (en/ru/zh). ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
|
||||
- **Cards / settings / modal / toolbar polish** — reworked mod-card colors, sections, channel-stripe styling, hairline borders, and signal-flow animation on running cards; multiselect bulk toolbar gets explicit Select-all / Deselect-all icons with luxury-gradient toolbar styling; Settings tabs are now icon-only (no overflow at any locale); modal exit animation gains symmetric fadeOut + slideDown keyframes with reduced-motion support; locale picker collapses to EN / RU / ZH; snack toast adopts a glass background with per-type accent. ([a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b))
|
||||
- **Suppress browser auto-open on Windows login** — when "Start with Windows" is enabled, the autostart shortcut now passes `--autostart` so the WebUI tab no longer pops on every login. Manual launches and the installer's "Launch LedGrab" finish-page action are unchanged. ([de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44))
|
||||
- **Simpler segment payloads** — `SegmentPayload.start` defaults to 0 and `length` defaults to "the rest of the strip from start". A single segment with only `mode` + `color` now fills the entire strip — no more `length: 9999` magic value clients had to pass. ([1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5))
|
||||
- About panel now houses the author + contact details that previously lived in a global app footer, freeing up vertical space across every page (en/ru/zh `donation.about_author` key added). ([816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d))
|
||||
|
||||
### Performance
|
||||
- **LED hot path is allocation-free per-frame**: Adalight gets a dedicated single-worker tx executor, pre-allocated wire buffer, uint8 scratch, and a precomputed header struct; DDP gets a pre-built `struct.Struct` and memoryview emit path; calibration precomputes Phase 3 skip-LED resampling so per-frame work is now `np.take` + in-place blend; the WLED target processor gets a matching tightening. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
|
||||
|
||||
### 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))
|
||||
- **Audio-source modal preserves device on refresh** — refresh button moved into the label row (no more overflow past the Source panel edge); selection is restored by matching on `(index, loopback)` first with a trimmed-name fallback for OS-side reindexing; the EntitySelect trigger now syncs so the visible label matches the underlying `<select>` in edit mode. ([0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4))
|
||||
- **PWA meta tag** — add the standard `mobile-web-app-capable` tag while keeping the Apple variant for iOS Safari, since Chrome deprecated `apple-mobile-web-app-capable`. ([8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### 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))
|
||||
#### CI/Build
|
||||
- Add `workflow_dispatch` and skip lint/test on release commits (release.yml already runs in parallel; manual dispatch covers re-runs on demand). ([033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6))
|
||||
|
||||
#### Tests
|
||||
- New `test_camera_engine` suite covers the new capture path. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
|
||||
- Adalight + DDP tests cover header format, buffer reuse, non-contiguous input, brightness scaling, RGB/RGBW packets, sequence/PUSH semantics, and multi-packet fragmentation. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
|
||||
- 13 new tests for the device-event notifications backend (full suite still 899 passing). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
|
||||
- `conftest` pre-creates the test DB so `main.py`'s legacy-data migration no longer shovels the user's production DB into the test temp dir; `test_preferences_notifications` wipes its own setting at the start of the defaults test (was relying on isolation it never enforced). ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
|
||||
|
||||
#### Tooling
|
||||
- `.mcp.json` checked in with code-review-graph MCP server config so the graph tools are available out of the box. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
|
||||
|
||||
---
|
||||
|
||||
@@ -30,16 +45,19 @@ 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 |
|
||||
| [0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4) | fix(ui): audio-source modal — preserve device on refresh, relocate refresh action | alexei.dolgolyov |
|
||||
| [fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b) | feat: daylight tz, camera engine, value stream + modal/UI polish | alexei.dolgolyov |
|
||||
| [816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d) | refactor(ui): drop app footer, move author info to About panel | alexei.dolgolyov |
|
||||
| [797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806) | feat: LED hot-path perf, tutorials expansion, modal markup polish | alexei.dolgolyov |
|
||||
| [9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534) | feat(ui): release notes overlay v2 + settings/streams/dashboard polish | alexei.dolgolyov |
|
||||
| [51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2) | feat(ui): redesign target pipeline as compact strip + chip row | alexei.dolgolyov |
|
||||
| [9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2) | feat(ui): align Targets metric cells with dashboard pattern | alexei.dolgolyov |
|
||||
| [233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463) | feat(ui): migrate Targets cards to mod-card system | alexei.dolgolyov |
|
||||
| [de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44) | feat(autostart): suppress browser auto-open on Windows login | alexei.dolgolyov |
|
||||
| [1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5) | feat(api-input): make SegmentPayload start/length optional | alexei.dolgolyov |
|
||||
| [a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b) | feat(ui): cards redesign + settings, modal, toolbar polish | alexei.dolgolyov |
|
||||
| [8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32) | feat(notifications): device event notifications (snack + Web Notifications) | alexei.dolgolyov |
|
||||
| [8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3) | fix(pwa): add mobile-web-app-capable meta tag | alexei.dolgolyov |
|
||||
| [033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6) | ci: add workflow_dispatch and skip lint/test on release commits | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
# LedGrab TODO
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.0"
|
||||
|
||||
ndk {
|
||||
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
||||
|
||||
+4
-1
@@ -162,8 +162,11 @@ Section "Desktop shortcut" SecDesktop
|
||||
SectionEnd
|
||||
|
||||
Section "Start with Windows" SecAutostart
|
||||
; Pass --autostart so the VBS sets LEDGRAB_AUTOSTART=1 and the app suppresses
|
||||
; the browser auto-open on Windows login. Manual launches (desktop / start
|
||||
; menu) don't pass the arg, so they keep opening the WebUI tab.
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}" --autostart' \
|
||||
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.0"
|
||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
authors = [
|
||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||
|
||||
@@ -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,),
|
||||
|
||||
@@ -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,
|
||||
@@ -121,10 +119,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
|
||||
|
||||
@@ -316,6 +316,7 @@ async def get_ha_status(
|
||||
name=source.name,
|
||||
connected=connected,
|
||||
entity_count=status["entity_count"] if status else 0,
|
||||
host=source.host or "",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -361,11 +363,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 +376,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:
|
||||
|
||||
@@ -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,33 @@ logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
||||
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
||||
|
||||
|
||||
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 +116,88 @@ 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"]
|
||||
|
||||
@@ -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
|
||||
@@ -137,14 +139,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)
|
||||
|
||||
@@ -255,6 +255,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),
|
||||
)
|
||||
|
||||
@@ -105,6 +105,7 @@ _RESPONSE_MAP = {
|
||||
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,
|
||||
),
|
||||
@@ -127,6 +128,7 @@ _RESPONSE_MAP = {
|
||||
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,
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -126,11 +126,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 +236,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")],
|
||||
@@ -303,11 +297,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 +420,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")],
|
||||
@@ -493,11 +481,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 +602,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 +637,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(
|
||||
|
||||
@@ -94,6 +94,7 @@ class HomeAssistantConnectionStatus(BaseModel):
|
||||
name: str
|
||||
connected: bool
|
||||
entity_count: int
|
||||
host: str = ""
|
||||
|
||||
|
||||
class HomeAssistantStatusResponse(BaseModel):
|
||||
|
||||
@@ -280,6 +280,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"
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
@@ -52,6 +52,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):
|
||||
|
||||
@@ -73,6 +73,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 +88,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):
|
||||
@@ -215,6 +219,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 +241,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):
|
||||
@@ -356,6 +368,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 +384,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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -224,6 +224,7 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -406,6 +425,13 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping automation engine: {e}")
|
||||
|
||||
# Stop discovery watcher (before health monitor stop so events still flow)
|
||||
if discovery_watcher is not None:
|
||||
try:
|
||||
await discovery_watcher.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping discovery watcher: {e}")
|
||||
|
||||
# Stop OS notification listener
|
||||
try:
|
||||
os_notif_listener.stop()
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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(380px, 100%), 1fr));
|
||||
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,568 @@ 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__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 lays flat over the track, transparent, so the user
|
||||
drags the visual track without seeing the native control. */
|
||||
.mod-fader { position: relative; }
|
||||
.mod-fader__slider {
|
||||
position: absolute;
|
||||
left: 52px;
|
||||
right: 50px; /* between label and value cells */
|
||||
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);
|
||||
@@ -1107,6 +1159,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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -404,6 +440,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;
|
||||
@@ -1139,6 +1203,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 +1219,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 +1344,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 +1468,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 +1478,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;
|
||||
|
||||
@@ -151,8 +151,6 @@ h1 {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.transport-status {
|
||||
@@ -382,26 +380,27 @@ h2 {
|
||||
font-family: var(--font-mono, 'Orbitron', sans-serif);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
background: transparent;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
transition: background 0.3s, color 0.3s, box-shadow 0.3s;
|
||||
transition: background 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
#server-version.has-update {
|
||||
background: var(--warning-color);
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
border-color: var(--ch-signal, var(--primary-color));
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
animation: updatePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes updatePulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(255, 152, 0, 0); }
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); }
|
||||
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent); }
|
||||
}
|
||||
|
||||
/* ── Update banner ── */
|
||||
@@ -770,12 +769,12 @@ h2 {
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border: none;
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||
transition: color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
line-height: 1;
|
||||
@@ -785,7 +784,6 @@ h2 {
|
||||
.header-btn:hover {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
border-color: var(--lux-line-bold, var(--border-color));
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
|
||||
}
|
||||
|
||||
@@ -888,38 +886,6 @@ h2 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.app-footer {
|
||||
margin-top: 12px;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.footer-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
.footer-content strong {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.footer-content a {
|
||||
color: var(--primary-text-color);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.footer-content a:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Command Palette */
|
||||
#command-palette {
|
||||
position: fixed;
|
||||
|
||||
@@ -481,11 +481,6 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.app-footer {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
/* Command palette */
|
||||
#command-palette {
|
||||
padding-top: 5vh;
|
||||
|
||||
+3230
-313
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@
|
||||
|
||||
.template-card {
|
||||
--ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */
|
||||
background: var(--card-bg);
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-md, var(--radius-md));
|
||||
padding: 18px 20px 16px;
|
||||
@@ -834,55 +834,89 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
|
||||
.cs-filter-wrap {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
max-width: 40%;
|
||||
width: 220px;
|
||||
max-width: 45%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Search input with embedded magnifier icon (data URI keeps the HTML
|
||||
untouched), neon focus glow, monospace placeholder for the technical
|
||||
look used elsewhere in the dashboard. */
|
||||
.cs-filter-wrap .cs-filter {
|
||||
width: 100%;
|
||||
padding: 4px 26px 4px 10px;
|
||||
font-size: 0.78rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-color);
|
||||
padding: 7px 32px 7px 32px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.04em;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-md, 6px);
|
||||
background-color: var(--lux-bg-0, var(--bg-secondary));
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a8a8a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: 10px center;
|
||||
background-size: 14px 14px;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s, background 0.2s, width 0.2s;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.cs-filter-wrap .cs-filter:hover {
|
||||
border-color: var(--lux-line-bold, var(--border-color));
|
||||
}
|
||||
|
||||
.cs-filter-wrap .cs-filter:focus {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--bg-color);
|
||||
background-color: var(--lux-bg-1, var(--bg-color));
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent),
|
||||
0 0 18px color-mix(in srgb, var(--primary-color) 22%, transparent);
|
||||
}
|
||||
|
||||
.cs-filter::placeholder {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
color: var(--lux-ink-faint, var(--text-secondary));
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cs-filter-reset {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
padding: 0 5px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
border-radius: 50%;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
/* Hide the clear button when the input is empty — CSS-only so the visual
|
||||
state stays correct regardless of any JS-set inline display value. */
|
||||
.cs-filter-wrap .cs-filter:placeholder-shown + .cs-filter-reset {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cs-filter-reset:hover {
|
||||
color: var(--text-color);
|
||||
background: var(--border-color);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Empty state for CardSection */
|
||||
|
||||
@@ -195,3 +195,346 @@
|
||||
}
|
||||
|
||||
/* target z-index for fixed overlay is set inline via JS (target is outside overlay DOM) */
|
||||
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
v2 "Signal Bench" — opt-in via .tutorial-v2 on the overlay root.
|
||||
Keeps the legacy ring+tooltip CSS untouched so unmigrated tours keep
|
||||
working. Aligns with the lux/instrument language used elsewhere in
|
||||
the UI (hairlines, JetBrains Mono labels, --ch-signal accent).
|
||||
────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Backdrop: dimmer page + a hint of grain so the cutout reads as an
|
||||
instrument viewfinder instead of a flat hole-punch. */
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-backdrop {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.018) 0px,
|
||||
rgba(255, 255, 255, 0.018) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
),
|
||||
radial-gradient(
|
||||
1400px 900px at 50% 35%,
|
||||
rgba(0, 0, 0, 0.78) 0%,
|
||||
rgba(0, 0, 0, 0.92) 100%
|
||||
);
|
||||
transition: clip-path 0.32s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-theme="light"] .tutorial-overlay.tutorial-v2 .tutorial-backdrop {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.02) 0px,
|
||||
rgba(0, 0, 0, 0.02) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
),
|
||||
radial-gradient(
|
||||
1400px 900px at 50% 35%,
|
||||
rgba(20, 24, 30, 0.55) 0%,
|
||||
rgba(20, 24, 30, 0.72) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Ring becomes a hairline + signal-glow frame; the prominent visual is
|
||||
the 4 corner brackets layered on top. */
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-ring {
|
||||
border: 1px dashed color-mix(in srgb, var(--ch-signal) 38%, transparent);
|
||||
border-radius: 2px;
|
||||
animation: none;
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--ch-signal) 14%, transparent),
|
||||
0 0 22px color-mix(in srgb, var(--ch-signal) 28%, transparent),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ch-signal) 6%, transparent);
|
||||
transition:
|
||||
left 0.28s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
top 0.28s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
width 0.28s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
height 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--ch-signal);
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--ch-signal) 55%, transparent));
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.tl {
|
||||
top: -2px; left: -2px;
|
||||
border-right: none; border-bottom: none;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.tr {
|
||||
top: -2px; right: -2px;
|
||||
border-left: none; border-bottom: none;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.bl {
|
||||
bottom: -2px; left: -2px;
|
||||
border-right: none; border-top: none;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.br {
|
||||
bottom: -2px; right: -2px;
|
||||
border-left: none; border-top: none;
|
||||
}
|
||||
|
||||
/* Animate corner draw-in on each step change (ring already eases its
|
||||
bounds; this adds a snappy "lock" on top). */
|
||||
.tutorial-overlay.tutorial-v2.step-changed .tutorial-reticle-corner {
|
||||
animation: tutorial-corner-lock 0.26s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes tutorial-corner-lock {
|
||||
0% { opacity: 0; transform: scale(2.4); }
|
||||
60% { opacity: 1; transform: scale(0.85); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Patch cable — connects reticle to tooltip. Animated dash-offset reads
|
||||
as transmission flowing toward the tooltip. Absolute by default for
|
||||
modal-mode overlays; viewport-fixed for tour overlays via the
|
||||
.tutorial-overlay-fixed class. */
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-cable {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 101;
|
||||
overflow: visible;
|
||||
}
|
||||
.tutorial-overlay-fixed.tutorial-v2 .tutorial-cable {
|
||||
position: fixed;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-cable line {
|
||||
stroke: var(--ch-signal);
|
||||
stroke-width: 1.25;
|
||||
stroke-dasharray: 4 5;
|
||||
stroke-linecap: round;
|
||||
fill: none;
|
||||
opacity: 0.7;
|
||||
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ch-signal) 50%, transparent));
|
||||
animation: tutorial-cable-flow 1.4s linear infinite;
|
||||
transition: all 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@keyframes tutorial-cable-flow {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -18; }
|
||||
}
|
||||
|
||||
/* Tooltip — instrument readout: square corners, hairline border,
|
||||
inner ring, lux shadow. */
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-tooltip {
|
||||
width: 296px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--lux-line-bold);
|
||||
border-radius: 2px;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--lux-bg-2),
|
||||
var(--lux-shadow-rack, 0 8px 24px rgba(0, 0, 0, 0.5)),
|
||||
0 0 32px color-mix(in srgb, var(--ch-signal) 12%, transparent);
|
||||
animation: tutorial-tooltip-v2-in 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--ch-signal) 20%,
|
||||
var(--ch-signal) 80%,
|
||||
transparent 100%
|
||||
);
|
||||
opacity: 0.85;
|
||||
}
|
||||
@keyframes tutorial-tooltip-v2-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 8px;
|
||||
border-bottom: 1px solid var(--lux-line);
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-eyebrow {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--ch-signal);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-step-counter {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--lux-ink-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tutorial-tooltip-breadcrumb {
|
||||
display: none;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tutorial-tooltip-breadcrumb.is-visible {
|
||||
display: inline-block;
|
||||
}
|
||||
.tutorial-tooltip-breadcrumb.is-visible + .tutorial-step-counter::before {
|
||||
content: '· ';
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
margin-right: 4px;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-close-btn {
|
||||
color: var(--lux-ink-mute);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-close-btn:hover {
|
||||
color: var(--lux-ink);
|
||||
background: var(--lux-bg-2);
|
||||
}
|
||||
|
||||
/* Segmented progress pips — one slot per step. */
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-pips {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-pip {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: var(--lux-line);
|
||||
border-radius: 1px;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-pip.done {
|
||||
background: color-mix(in srgb, var(--ch-signal) 60%, var(--lux-line));
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-pip.active {
|
||||
background: var(--ch-signal);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal) 60%, transparent);
|
||||
}
|
||||
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-text {
|
||||
margin: 0;
|
||||
padding: 8px 14px 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--lux-ink);
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--lux-line);
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn,
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-next-btn {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--lux-line-bold);
|
||||
border-radius: 2px;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn {
|
||||
background: transparent;
|
||||
color: var(--lux-ink-dim);
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn:hover:not(:disabled) {
|
||||
color: var(--lux-ink);
|
||||
border-color: var(--lux-ink-mute);
|
||||
background: var(--lux-bg-2);
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-next-btn {
|
||||
background: var(--ch-signal);
|
||||
color: var(--primary-contrast, #000);
|
||||
border-color: var(--ch-signal);
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-next-btn:hover:not(:disabled) {
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--ch-signal) 50%, transparent);
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Keyboard hint chip — surfaces existing arrow/Esc bindings. */
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-keyhint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 10px;
|
||||
border-top: 1px solid var(--lux-line);
|
||||
background: var(--lux-bg-1);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--lux-ink-mute);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-keyhint kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
background: var(--lux-bg-3);
|
||||
border: 1px solid var(--lux-line-bold);
|
||||
border-radius: 2px;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
color: var(--lux-ink-dim);
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-keyhint span {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-keyhint span:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Mobile — collapse cable, dock tooltip to bottom of viewport. */
|
||||
@media (max-width: 640px) {
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-cable { display: none; }
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-tooltip {
|
||||
width: calc(100vw - 24px);
|
||||
max-width: 360px;
|
||||
}
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-keyhint { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-cable line { animation: none; }
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-tooltip { animation: none; }
|
||||
.tutorial-overlay.tutorial-v2.step-changed .tutorial-reticle-corner { animation: none; }
|
||||
.tutorial-overlay.tutorial-v2 .tutorial-ring { transition: none; }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { initCardGlare } from './core/card-glare.ts';
|
||||
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts';
|
||||
import { initBgShaders } from './core/bg-shaders.ts';
|
||||
import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts';
|
||||
import { initModMenu } from './core/mod-menu.ts';
|
||||
import { toggleCardHidden } from './core/card-sections.ts';
|
||||
|
||||
// Layer 2: ui
|
||||
import {
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
import {
|
||||
startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial,
|
||||
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
|
||||
startIntegrationsTutorial,
|
||||
closeTutorial, tutorialNext, tutorialPrev,
|
||||
} from './features/tutorials.ts';
|
||||
|
||||
@@ -55,6 +58,7 @@ import {
|
||||
openDashboardCustomize, closeDashboardCustomize,
|
||||
} from './features/dashboard-customize.ts';
|
||||
import { startEventsWS, stopEventsWS } from './core/events-ws.ts';
|
||||
import { startNotificationsWatcher } from './features/notifications-watcher.ts';
|
||||
import { startEntityEventListeners } from './core/entity-events.ts';
|
||||
import {
|
||||
startPerfPolling, stopPerfPolling, setPerfMode,
|
||||
@@ -62,7 +66,7 @@ import {
|
||||
import {
|
||||
loadPictureSources, switchStreamTab,
|
||||
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
|
||||
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
|
||||
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest, openTestDisplayPicker,
|
||||
showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, saveAudioTemplate, deleteAudioTemplate,
|
||||
cloneAudioTemplate, onAudioEngineChange,
|
||||
showTestAudioTemplateModal, closeTestAudioTemplateModal, startAudioTemplateTest,
|
||||
@@ -103,7 +107,7 @@ import {
|
||||
} from './features/integrations.ts';
|
||||
import {
|
||||
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
|
||||
activateScenePreset, cloneScenePreset, deleteScenePreset,
|
||||
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
|
||||
addSceneTarget,
|
||||
} from './features/scene-presets.ts';
|
||||
|
||||
@@ -127,7 +131,6 @@ import {
|
||||
import {
|
||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
|
||||
colorCycleAddColor, colorCycleRemoveColor,
|
||||
compositeAddLayer, compositeRemoveLayer,
|
||||
mappedAddZone, mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
@@ -220,7 +223,9 @@ import {
|
||||
openLogOverlay, closeLogOverlay,
|
||||
loadLogLevel, setLogLevel,
|
||||
loadShutdownAction, setShutdownAction,
|
||||
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||
loadDaylightTimezone, saveDaylightTimezone,
|
||||
requestNotifPermissionFromSettings, testNotifFromSettings,
|
||||
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||
} from './features/settings.ts';
|
||||
import {
|
||||
loadUpdateStatus, initUpdateListener, checkForUpdates,
|
||||
@@ -238,6 +243,11 @@ Object.assign(window, {
|
||||
// core / state (for inline script)
|
||||
setApiKey,
|
||||
|
||||
// mod-card menu — referenced by inline onclick on .mod-menu__item
|
||||
// for the "Hide card" entry. The handler uses the registered
|
||||
// CardSection's section key (e.g. 'led-devices') + the card id.
|
||||
toggleCardHidden,
|
||||
|
||||
// visual effects (called from inline <script>)
|
||||
_updateBgAnimAccent: updateBgAnimAccent,
|
||||
_updateBgAnimTheme: updateBgAnimTheme,
|
||||
@@ -280,6 +290,7 @@ Object.assign(window, {
|
||||
startTargetsTutorial,
|
||||
startSourcesTutorial,
|
||||
startAutomationsTutorial,
|
||||
startIntegrationsTutorial,
|
||||
closeTutorial,
|
||||
tutorialNext,
|
||||
tutorialPrev,
|
||||
@@ -329,6 +340,7 @@ Object.assign(window, {
|
||||
closeTestTemplateModal,
|
||||
onEngineChange,
|
||||
runTemplateTest,
|
||||
openTestDisplayPicker,
|
||||
updateCaptureDuration,
|
||||
showAddStreamModal,
|
||||
editStream,
|
||||
@@ -407,7 +419,7 @@ Object.assign(window, {
|
||||
deleteAutomation,
|
||||
copyWebhookUrl,
|
||||
|
||||
// scene presets (modal buttons stay on window; card actions migrated to event delegation)
|
||||
// scene presets — modal buttons + mod-card inline handlers
|
||||
openScenePresetCapture,
|
||||
editScenePreset,
|
||||
saveScenePreset,
|
||||
@@ -415,6 +427,7 @@ Object.assign(window, {
|
||||
activateScenePreset,
|
||||
cloneScenePreset,
|
||||
deleteScenePreset,
|
||||
recaptureScenePreset,
|
||||
addSceneTarget,
|
||||
|
||||
// integrations
|
||||
@@ -476,8 +489,6 @@ Object.assign(window, {
|
||||
onCSSClockChange,
|
||||
onAnimationTypeChange,
|
||||
onDaylightRealTimeChange,
|
||||
colorCycleAddColor,
|
||||
colorCycleRemoveColor,
|
||||
compositeAddLayer,
|
||||
compositeRemoveLayer,
|
||||
mappedAddZone,
|
||||
@@ -618,7 +629,12 @@ Object.assign(window, {
|
||||
setLogLevel,
|
||||
loadShutdownAction,
|
||||
setShutdownAction,
|
||||
loadDaylightTimezone,
|
||||
saveDaylightTimezone,
|
||||
requestNotifPermissionFromSettings,
|
||||
testNotifFromSettings,
|
||||
saveExternalUrl,
|
||||
revertExternalUrl,
|
||||
getBaseOrigin,
|
||||
|
||||
// update
|
||||
@@ -733,6 +749,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// Initialize visual effects
|
||||
initCardGlare();
|
||||
initModMenu();
|
||||
initBgAnim();
|
||||
initBgShaders();
|
||||
initAppearance();
|
||||
@@ -817,6 +834,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Start global events WebSocket and auto-refresh
|
||||
startEventsWS();
|
||||
startEntityEventListeners();
|
||||
// Device-event notifications (snack/Web Notification per user prefs).
|
||||
// Must start *after* startEventsWS so its DOM listeners catch the
|
||||
// first events fired during the startup grace window.
|
||||
startNotificationsWatcher().catch(() => {});
|
||||
startAutoRefresh();
|
||||
// Perf poll starts globally so the transport-bar CPU / Mem cells stay
|
||||
// live regardless of which tab is active. Tab-hidden pauses it via the
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { t } from './i18n.ts';
|
||||
import { showConfirm } from './ui.ts';
|
||||
import { ICON_LIST_CHECKS, ICON_CIRCLE_OFF, ICON_X } from './icons.ts';
|
||||
import type { CardSection } from './card-sections.ts';
|
||||
|
||||
let _activeSection: CardSection | null = null; // CardSection currently in bulk mode
|
||||
@@ -53,28 +54,40 @@ function _render() {
|
||||
if (!section) { el.classList.remove('visible'); return; }
|
||||
|
||||
const count = section._selected.size;
|
||||
const total = section._visibleCardCount();
|
||||
const actions = section.bulkActions || [];
|
||||
const allSelected = count > 0 && count === total;
|
||||
const noneSelected = count === 0;
|
||||
|
||||
const actionBtns = actions.map(a => {
|
||||
const cls = a.style === 'danger' ? 'btn btn-icon btn-danger bulk-action-btn' : 'btn btn-icon btn-secondary bulk-action-btn';
|
||||
const label = t(a.labelKey);
|
||||
const inner = a.icon || label;
|
||||
return `<button class="${cls}" data-bulk-action="${a.key}" title="${label}">${inner}</button>`;
|
||||
const disabled = noneSelected ? ' disabled aria-disabled="true"' : '';
|
||||
return `<button class="${cls}"${disabled} data-bulk-action="${a.key}" title="${label}">${inner}</button>`;
|
||||
}).join('');
|
||||
|
||||
// Two distinct icon buttons replace the previous tri-state checkbox so
|
||||
// the affordance is unambiguous: pick everything, or clear the picks.
|
||||
const selectAllDisabled = allSelected ? ' disabled aria-disabled="true"' : '';
|
||||
const deselectAllDisabled = noneSelected ? ' disabled aria-disabled="true"' : '';
|
||||
|
||||
el.innerHTML = `
|
||||
<label class="bulk-select-all-wrap" title="${t('bulk.select_all')}">
|
||||
<input type="checkbox" class="bulk-select-all-cb"${count > 0 && count === section._visibleCardCount() ? ' checked' : ''}>
|
||||
</label>
|
||||
<span class="bulk-count">${t('bulk.selected_count', { count })}</span>
|
||||
<div class="bulk-pick">
|
||||
<button class="bulk-pick-btn" data-bulk-pick="all"${selectAllDisabled} title="${t('bulk.select_all')}" aria-label="${t('bulk.select_all')}">${ICON_LIST_CHECKS}</button>
|
||||
<button class="bulk-pick-btn" data-bulk-pick="none"${deselectAllDisabled} title="${t('bulk.deselect_all')}" aria-label="${t('bulk.deselect_all')}">${ICON_CIRCLE_OFF}</button>
|
||||
</div>
|
||||
<span class="bulk-count" aria-live="polite">${t('bulk.selected_count', { count })}<small class="bulk-count-total"> / ${total}</small></span>
|
||||
<div class="bulk-actions">${actionBtns}</div>
|
||||
<button class="bulk-close" title="${t('bulk.cancel')}">✕</button>
|
||||
<button class="bulk-close" title="${t('bulk.cancel')}" aria-label="${t('bulk.cancel')}">${ICON_X}</button>
|
||||
`;
|
||||
|
||||
// Select All checkbox
|
||||
el.querySelector('.bulk-select-all-cb')!.addEventListener('change', (e) => {
|
||||
if ((e.target as HTMLInputElement).checked) section.selectAll();
|
||||
else section.deselectAll();
|
||||
// Pick buttons
|
||||
el.querySelector('[data-bulk-pick="all"]')!.addEventListener('click', () => {
|
||||
section.selectAll();
|
||||
});
|
||||
el.querySelector('[data-bulk-pick="none"]')!.addEventListener('click', () => {
|
||||
section.deselectAll();
|
||||
});
|
||||
|
||||
// Action buttons
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
import { createColorPicker, registerColorPicker } from './color-picker.ts';
|
||||
import { ICON_TRASH } from './icons.ts';
|
||||
import { renderModCardInner } from './mod-card.ts';
|
||||
import type { ModCardOpts } from './mod-card.ts';
|
||||
|
||||
const STORAGE_KEY = 'cardColors';
|
||||
const DEFAULT_SWATCH = '#808080';
|
||||
@@ -63,6 +65,25 @@ export function setCardColor(id: string, hex: string): void {
|
||||
const card = el as HTMLElement;
|
||||
if (hex) card.style.setProperty('--ch', hex);
|
||||
else card.style.removeProperty('--ch');
|
||||
|
||||
// Mod-card variant: also sync the leading colour-dot inside
|
||||
// .mod-badge. The picker's `_cpPick` already inlined a
|
||||
// background:hex on the swatch — clear it so the dot reverts
|
||||
// to its channel-color default when the user resets.
|
||||
const dot = card.querySelector(`#cp-swatch-cc-${escaped}`) as HTMLElement | null;
|
||||
if (dot && dot.classList.contains('mod-badge__color')) {
|
||||
if (hex) {
|
||||
dot.dataset.custom = '1';
|
||||
dot.style.setProperty('--user-color', hex);
|
||||
// Clear any inline `background:hex` set by _cpPick so the
|
||||
// CSS [data-custom] rule wins (background = var(--user-color)).
|
||||
dot.style.removeProperty('background');
|
||||
} else {
|
||||
delete dot.dataset.custom;
|
||||
dot.style.removeProperty('--user-color');
|
||||
dot.style.removeProperty('background');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,36 +115,97 @@ export function cardColorButton(entityId: string, cardAttr: string): string {
|
||||
return createColorPicker({ id: pickerId, currentColor: color, onPick: undefined, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mod-card variant of `cardColorButton` — emits the leading
|
||||
* `.mod-badge__color` dot inside a `.mod-badge`. The dot IS the
|
||||
* picker swatch (same id as the legacy swatch so `_cpToggle()` finds
|
||||
* it). The popover is appended as a sibling of the badge so it can
|
||||
* detach to <body> with fixed positioning when needed (the existing
|
||||
* picker logic in color-picker.ts handles that automatically).
|
||||
*
|
||||
* Returns inline HTML to be injected as the first child of
|
||||
* `.mod-badge`. No surrounding span — the dot is the badge's leading
|
||||
* element directly.
|
||||
*/
|
||||
export function cardColorDot(entityId: string, cardAttr: string): string {
|
||||
const color = getCardColor(entityId);
|
||||
const pickerId = `cc-${entityId}`;
|
||||
|
||||
registerColorPicker(pickerId, (hex) => {
|
||||
setCardColor(entityId, hex);
|
||||
});
|
||||
|
||||
// The dot doubles as the picker swatch. Inline `--user-color` is
|
||||
// applied only when the user has picked a personal hue; absent it,
|
||||
// the dot inherits the channel colour from the parent badge via
|
||||
// the `.mod-badge__color` rule in cards.css.
|
||||
const customAttr = color ? ` data-custom="1" style="--user-color:${color}"` : '';
|
||||
const dataAttr = ` data-card-attr="${cardAttr}"`;
|
||||
|
||||
return `<span class="color-picker-wrapper mod-badge__color-wrap" id="cp-wrap-${entityId}">` +
|
||||
`<span class="mod-badge__color color-picker-swatch" id="cp-swatch-${pickerId}" tabindex="0" role="button" aria-label="${getCardColorAriaLabel()}" onclick="event.stopPropagation(); window._cpToggle('${pickerId}')"${customAttr}${dataAttr}></span>` +
|
||||
_colorPopoverHtml(pickerId, color || DEFAULT_SWATCH) +
|
||||
`</span>`;
|
||||
}
|
||||
|
||||
/** Build just the popover portion (the swatch is rendered separately
|
||||
* by `cardColorDot`). Mirrors `createColorPicker`'s popover output. */
|
||||
function _colorPopoverHtml(pickerId: string, currentColor: string): string {
|
||||
// Reuse createColorPicker's full output but strip the wrapping
|
||||
// .color-picker-wrapper and the legacy round swatch — we only need
|
||||
// the popover. Easier: render the full widget into a temporary
|
||||
// string and extract the popover. But that adds complexity. Since
|
||||
// the popover markup is stable, inline it here.
|
||||
const PRESETS = ['#4CAF50', '#7C4DFF', '#FF6D00', '#E91E63', '#00BCD4', '#FF5252', '#26A69A', '#2196F3', '#FFC107'];
|
||||
const dots = PRESETS.map(c => {
|
||||
const active = c.toLowerCase() === currentColor.toLowerCase() ? ' active' : '';
|
||||
return `<button class="color-picker-dot${active}" style="background:${c}" aria-label="${c}" onclick="event.stopPropagation(); window._cpPick('${pickerId}','${c}')"></button>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="color-picker-popover anchor-left" id="cp-pop-${pickerId}" style="display:none" onclick="event.stopPropagation()">` +
|
||||
`<div class="color-picker-grid">${dots}</div>` +
|
||||
`<div class="color-picker-custom" onclick="this.querySelector('input').click()">` +
|
||||
`<input type="color" id="cp-native-${pickerId}" value="${currentColor}" ` +
|
||||
`oninput="event.stopPropagation(); window._cpPick('${pickerId}',this.value)" ` +
|
||||
`onchange="event.stopPropagation(); window._cpPick('${pickerId}',this.value)">` +
|
||||
`<span>Custom</span>` +
|
||||
`</div>` +
|
||||
`<div class="color-picker-reset" onclick="event.stopPropagation(); window._cpReset('${pickerId}','${DEFAULT_SWATCH}')"><span>Reset</span></div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function getCardColorAriaLabel(): string {
|
||||
// i18n is not always loaded when this module evaluates (renders
|
||||
// happen during early page boot). Fall back to English.
|
||||
return (window as any).__t?.('common.card_color') || 'Card color';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a standard card shell with color support.
|
||||
*
|
||||
* Provides consistent structure across all card types:
|
||||
* - .card-top-actions: remove button + optional extra top buttons
|
||||
* - Bottom actions: action buttons + color picker (always last)
|
||||
* - Automatic border-left color from localStorage
|
||||
* Two rendering paths:
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {'card'|'template-card'} [opts.type='card'] Card CSS class
|
||||
* @param {string} opts.dataAttr Data attribute name, e.g. 'data-device-id'
|
||||
* @param {string} opts.id Entity ID value
|
||||
* @param {string} [opts.classes] Extra CSS classes on root element
|
||||
* @param {string} [opts.topButtons] HTML for extra top-right buttons (power, autostart)
|
||||
* @param {string} opts.removeOnclick onclick handler string for remove button
|
||||
* @param {string} opts.removeTitle title attribute for remove button
|
||||
* @param {string} opts.content Inner HTML (header, props, metrics, etc.)
|
||||
* @param {string} opts.actions Action button HTML (without wrapper div)
|
||||
* 1. Legacy (content/actions) — emits `.card-top-actions` with the
|
||||
* remove button and a bottom `.card-actions` row with action
|
||||
* buttons + colour picker. Used by all cards that haven't been
|
||||
* migrated to the modular system yet.
|
||||
*
|
||||
* 2. Modular (`mod`) — emits the dashboard's `.mod-head / .mod-body /
|
||||
* .mod-foot` markup with a kebab overflow menu housing duplicate /
|
||||
* hide / delete. Card-color picker is integrated into the
|
||||
* `.mod-badge` as a leading dot. Use this for any new card and
|
||||
* when migrating existing card builders.
|
||||
*
|
||||
* Old callers keep working unchanged. New callers pass `mod`.
|
||||
*/
|
||||
export function wrapCard({
|
||||
type = 'card',
|
||||
dataAttr,
|
||||
id,
|
||||
classes = '',
|
||||
topButtons = '',
|
||||
removeOnclick,
|
||||
removeTitle,
|
||||
content,
|
||||
actions,
|
||||
}: {
|
||||
export function wrapCard(opts: WrapCardLegacyOpts | WrapCardModOpts): string {
|
||||
if ('mod' in opts && opts.mod) {
|
||||
return _renderModCard(opts as WrapCardModOpts);
|
||||
}
|
||||
return _renderLegacyCard(opts as WrapCardLegacyOpts);
|
||||
}
|
||||
|
||||
export interface WrapCardLegacyOpts {
|
||||
type?: 'card' | 'template-card';
|
||||
dataAttr: string;
|
||||
id: string;
|
||||
@@ -133,7 +215,28 @@ export function wrapCard({
|
||||
removeTitle: string;
|
||||
content: string;
|
||||
actions: string;
|
||||
}): string {
|
||||
mod?: undefined;
|
||||
}
|
||||
|
||||
export interface WrapCardModOpts {
|
||||
type?: 'card' | 'template-card';
|
||||
dataAttr: string;
|
||||
id: string;
|
||||
classes?: string;
|
||||
mod: ModCardOpts;
|
||||
}
|
||||
|
||||
function _renderLegacyCard({
|
||||
type = 'card',
|
||||
dataAttr,
|
||||
id,
|
||||
classes = '',
|
||||
topButtons = '',
|
||||
removeOnclick,
|
||||
removeTitle,
|
||||
content,
|
||||
actions,
|
||||
}: WrapCardLegacyOpts): string {
|
||||
const actionsClass = type === 'template-card' ? 'template-card-actions' : 'card-actions';
|
||||
const colorStyle = cardColorStyle(id);
|
||||
return `
|
||||
@@ -149,3 +252,39 @@ export function wrapCard({
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderModCard({
|
||||
type = 'card',
|
||||
dataAttr,
|
||||
id,
|
||||
classes = '',
|
||||
mod,
|
||||
}: WrapCardModOpts): string {
|
||||
// mod-card.ts and card-colors.ts have a cyclic import (mod-card uses
|
||||
// cardColorDot, card-colors uses renderModCardInner). The cycle is
|
||||
// safe because both modules only call each other inside function
|
||||
// bodies — never during module initialization. ESM live bindings
|
||||
// resolve correctly by the time these functions actually run.
|
||||
const inner = renderModCardInner(_withColorBindings(mod, id, dataAttr));
|
||||
const colorStyle = cardColorStyle(id);
|
||||
const runningCls = mod.running ? ' card-running is-running' : '';
|
||||
const colorAttr = colorStyle ? ` style="${colorStyle}" data-has-color="1"` : '';
|
||||
return `
|
||||
<div class="${type} mod-card${classes ? ' ' + classes : ''}${runningCls}" ${dataAttr}="${id}"${colorAttr}>
|
||||
${inner}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Inject entityId/cardAttr into the head opts so the badge dot can
|
||||
* register its picker without callers needing to pass the same id
|
||||
* twice. */
|
||||
function _withColorBindings(mod: ModCardOpts, id: string, dataAttr: string): ModCardOpts {
|
||||
return {
|
||||
...mod,
|
||||
head: {
|
||||
...mod.head,
|
||||
entityId: mod.head.entityId ?? id,
|
||||
cardAttr: mod.head.cardAttr ?? dataAttr,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
import { t } from './i18n.ts';
|
||||
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts';
|
||||
import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF } from './icons.ts';
|
||||
import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF, ICON_CHECK } from './icons.ts';
|
||||
|
||||
export interface BulkAction {
|
||||
key: string;
|
||||
@@ -316,30 +316,46 @@ export class CardSection {
|
||||
});
|
||||
}
|
||||
|
||||
// Card click delegation for selection
|
||||
// Ctrl+Click on a card auto-enters bulk mode if not already selecting
|
||||
// Card click delegation for selection.
|
||||
//
|
||||
// When in selection mode, the entire card becomes the click
|
||||
// target — clicking anywhere on it toggles selection. Inner
|
||||
// controls (buttons, sliders, kebab, color dot) are visually
|
||||
// disabled via `.cs-selecting *` pointer-events:none in CSS,
|
||||
// so events pass through to the card root.
|
||||
//
|
||||
// Outside selection mode, Ctrl/Cmd+Click on the card body
|
||||
// (not on a button/input) auto-enters bulk mode and selects
|
||||
// that card.
|
||||
content.addEventListener('click', (e: MouseEvent) => {
|
||||
if (!this.keyAttr) return;
|
||||
const card = (e.target as HTMLElement).closest(`[${this.keyAttr}]`);
|
||||
if (!card) return;
|
||||
// Don't hijack clicks on buttons, links, inputs inside cards
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, .card-actions, .template-card-actions, .color-picker-wrapper')) return;
|
||||
|
||||
// Auto-enter selection mode on Ctrl/Cmd+Click
|
||||
if (!this._selecting && (e.ctrlKey || e.metaKey)) {
|
||||
if (this._selecting) {
|
||||
// pointer-events:none on descendants means events
|
||||
// already bypass inner controls; just toggle.
|
||||
const key = card.getAttribute(this.keyAttr);
|
||||
if (!key) return;
|
||||
if (e.shiftKey && this._lastClickedKey) {
|
||||
this._selectRange(content, this._lastClickedKey, key);
|
||||
} else {
|
||||
this._toggleSelect(key);
|
||||
}
|
||||
this._lastClickedKey = key;
|
||||
return;
|
||||
}
|
||||
|
||||
// Not selecting — only auto-enter on Ctrl/Cmd+Click
|
||||
// outside any interactive control.
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, .card-actions, .template-card-actions, .color-picker-wrapper, .mod-menu-wrap, .mod-fader')) return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
this.enterSelectionMode();
|
||||
}
|
||||
if (!this._selecting) return;
|
||||
|
||||
const key = card.getAttribute(this.keyAttr);
|
||||
if (!key) return;
|
||||
|
||||
if (e.shiftKey && this._lastClickedKey) {
|
||||
this._selectRange(content, this._lastClickedKey, key);
|
||||
} else {
|
||||
const key = card.getAttribute(this.keyAttr);
|
||||
if (!key) return;
|
||||
this._toggleSelect(key);
|
||||
this._lastClickedKey = key;
|
||||
}
|
||||
this._lastClickedKey = key;
|
||||
});
|
||||
|
||||
// Escape to exit selection mode
|
||||
@@ -358,6 +374,7 @@ export class CardSection {
|
||||
if (this.keyAttr) {
|
||||
this._injectHideButtons(content);
|
||||
this._injectDragHandles(content);
|
||||
this._injectBulkTicks(content);
|
||||
this._initDrag(content);
|
||||
}
|
||||
|
||||
@@ -463,10 +480,11 @@ export class CardSection {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-inject hide buttons and drag handles on new/replaced cards
|
||||
// Re-inject hide buttons, drag handles, bulk ticks on new/replaced cards
|
||||
if (this.keyAttr && (added.size > 0 || replaced.size > 0)) {
|
||||
this._injectHideButtons(content);
|
||||
this._injectDragHandles(content);
|
||||
this._injectBulkTicks(content);
|
||||
}
|
||||
|
||||
// Re-cache searchable text for new/replaced cards
|
||||
@@ -638,28 +656,31 @@ export class CardSection {
|
||||
});
|
||||
}
|
||||
|
||||
_injectCheckboxes(content: HTMLElement) {
|
||||
_injectCheckboxes(_content: HTMLElement) {
|
||||
// Selection mode now uses the card root as the click target —
|
||||
// no per-card checkbox is injected. The visual indicator is the
|
||||
// `.card-selected` border + box-shadow defined in CSS, plus
|
||||
// pointer-events:none on descendants so the entire card behaves
|
||||
// as one big toggle. Kept as a no-op so existing callsites in
|
||||
// bind() / reconcile() don't need changes.
|
||||
}
|
||||
|
||||
/** Inject a single .mod-bulk-tick element per card. Hidden by CSS
|
||||
* unless the card is `.card-selected` AND the section is
|
||||
* `.cs-selecting` — so it costs nothing visually outside bulk mode
|
||||
* and renders a corner checkmark when both flags are on. */
|
||||
_injectBulkTicks(content: HTMLElement) {
|
||||
if (!this.keyAttr) return;
|
||||
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
|
||||
if (card.querySelector('.card-bulk-check')) return;
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'card-bulk-check';
|
||||
cb.checked = this._selected.has(card.getAttribute(this.keyAttr)!);
|
||||
cb.addEventListener('click', (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const key = card.getAttribute(this.keyAttr)!;
|
||||
if (e.shiftKey && this._lastClickedKey) {
|
||||
this._selectRange(content, this._lastClickedKey, key);
|
||||
} else {
|
||||
this._toggleSelect(key);
|
||||
}
|
||||
this._lastClickedKey = key;
|
||||
});
|
||||
// Insert as first child of .card-top-actions, or prepend to card
|
||||
const topActions = card.querySelector('.card-top-actions');
|
||||
if (topActions) topActions.prepend(cb);
|
||||
else card.prepend(cb);
|
||||
// Strip any legacy injected checkboxes from older deploys
|
||||
const stale = card.querySelector('.card-bulk-check');
|
||||
if (stale) stale.remove();
|
||||
if (card.querySelector('.mod-bulk-tick')) return;
|
||||
const tick = document.createElement('span');
|
||||
tick.className = 'mod-bulk-tick';
|
||||
tick.setAttribute('aria-hidden', 'true');
|
||||
tick.innerHTML = ICON_CHECK;
|
||||
card.appendChild(tick);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Daylight-cycle timezone picker.
|
||||
*
|
||||
* Exposes:
|
||||
* - `getTimezoneItems()` — IconSelectItem[] for the EntityPalette / EntitySelect
|
||||
* - `enhanceTimezoneSelect(target, value, onChange?)` — replace a `<select>` with
|
||||
* a refined EntitySelect picker. Idempotent; safe to call on every modal open.
|
||||
* - `populateTimezoneSelect(id, value)` — back-compat for legacy callers that
|
||||
* still want a plain native `<select>`.
|
||||
*
|
||||
* Each entry exposes a friendly city name and the timezone's *current* UTC
|
||||
* offset (computed via `Intl.DateTimeFormat`), so users see `Berlin · UTC+02:00`
|
||||
* instead of bare IANA strings like `Europe/Berlin`.
|
||||
*/
|
||||
|
||||
import { t } from './i18n.ts';
|
||||
import {
|
||||
ICON_GLOBE,
|
||||
ICON_SPARKLES,
|
||||
ICON_TARGET_ICON,
|
||||
ICON_CLOCK,
|
||||
} from './icons.ts';
|
||||
import type { IconSelectItem } from './icon-select.ts';
|
||||
import { EntitySelect } from './entity-palette.ts';
|
||||
|
||||
type RegionKey =
|
||||
| 'utc'
|
||||
| 'europe'
|
||||
| 'africa'
|
||||
| 'me'
|
||||
| 'asia'
|
||||
| 'pacific'
|
||||
| 'americas';
|
||||
|
||||
interface TimezoneEntry {
|
||||
iana: string;
|
||||
region: RegionKey;
|
||||
city: string;
|
||||
}
|
||||
|
||||
const TIMEZONES: ReadonlyArray<TimezoneEntry> = [
|
||||
{ iana: 'UTC', region: 'utc', city: 'UTC' },
|
||||
|
||||
// Europe
|
||||
{ iana: 'Europe/London', region: 'europe', city: 'London' },
|
||||
{ iana: 'Europe/Lisbon', region: 'europe', city: 'Lisbon' },
|
||||
{ iana: 'Europe/Paris', region: 'europe', city: 'Paris' },
|
||||
{ iana: 'Europe/Berlin', region: 'europe', city: 'Berlin' },
|
||||
{ iana: 'Europe/Warsaw', region: 'europe', city: 'Warsaw' },
|
||||
{ iana: 'Europe/Helsinki', region: 'europe', city: 'Helsinki' },
|
||||
{ iana: 'Europe/Athens', region: 'europe', city: 'Athens' },
|
||||
{ iana: 'Europe/Moscow', region: 'europe', city: 'Moscow' },
|
||||
{ iana: 'Europe/Istanbul', region: 'europe', city: 'Istanbul' },
|
||||
|
||||
// Africa & Middle East
|
||||
{ iana: 'Africa/Cairo', region: 'africa', city: 'Cairo' },
|
||||
{ iana: 'Africa/Lagos', region: 'africa', city: 'Lagos' },
|
||||
{ iana: 'Africa/Johannesburg', region: 'africa', city: 'Johannesburg' },
|
||||
{ iana: 'Asia/Dubai', region: 'me', city: 'Dubai' },
|
||||
{ iana: 'Asia/Tehran', region: 'me', city: 'Tehran' },
|
||||
|
||||
// Asia
|
||||
{ iana: 'Asia/Karachi', region: 'asia', city: 'Karachi' },
|
||||
{ iana: 'Asia/Kolkata', region: 'asia', city: 'Kolkata' },
|
||||
{ iana: 'Asia/Bangkok', region: 'asia', city: 'Bangkok' },
|
||||
{ iana: 'Asia/Jakarta', region: 'asia', city: 'Jakarta' },
|
||||
{ iana: 'Asia/Singapore', region: 'asia', city: 'Singapore' },
|
||||
{ iana: 'Asia/Hong_Kong', region: 'asia', city: 'Hong Kong' },
|
||||
{ iana: 'Asia/Shanghai', region: 'asia', city: 'Shanghai' },
|
||||
{ iana: 'Asia/Seoul', region: 'asia', city: 'Seoul' },
|
||||
{ iana: 'Asia/Tokyo', region: 'asia', city: 'Tokyo' },
|
||||
|
||||
// Pacific
|
||||
{ iana: 'Australia/Perth', region: 'pacific', city: 'Perth' },
|
||||
{ iana: 'Australia/Adelaide', region: 'pacific', city: 'Adelaide' },
|
||||
{ iana: 'Australia/Sydney', region: 'pacific', city: 'Sydney' },
|
||||
{ iana: 'Pacific/Auckland', region: 'pacific', city: 'Auckland' },
|
||||
{ iana: 'Pacific/Honolulu', region: 'pacific', city: 'Honolulu' },
|
||||
|
||||
// Americas
|
||||
{ iana: 'America/Anchorage', region: 'americas', city: 'Anchorage' },
|
||||
{ iana: 'America/Los_Angeles', region: 'americas', city: 'Los Angeles' },
|
||||
{ iana: 'America/Denver', region: 'americas', city: 'Denver' },
|
||||
{ iana: 'America/Chicago', region: 'americas', city: 'Chicago' },
|
||||
{ iana: 'America/Mexico_City', region: 'americas', city: 'Mexico City' },
|
||||
{ iana: 'America/New_York', region: 'americas', city: 'New York' },
|
||||
{ iana: 'America/Toronto', region: 'americas', city: 'Toronto' },
|
||||
{ iana: 'America/Sao_Paulo', region: 'americas', city: 'São Paulo' },
|
||||
{ iana: 'America/Argentina/Buenos_Aires', region: 'americas', city: 'Buenos Aires' },
|
||||
];
|
||||
|
||||
const REGION_FALLBACK: Record<RegionKey, string> = {
|
||||
utc: 'UTC',
|
||||
europe: 'Europe',
|
||||
africa: 'Africa',
|
||||
me: 'Middle East',
|
||||
asia: 'Asia',
|
||||
pacific: 'Pacific',
|
||||
americas: 'Americas',
|
||||
};
|
||||
|
||||
function _detectedTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the current UTC offset for a given IANA zone, formatted as `UTC+02:00`. */
|
||||
function _utcOffsetLabel(iana: string): string {
|
||||
try {
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: iana,
|
||||
timeZoneName: 'longOffset',
|
||||
});
|
||||
const parts = fmt.formatToParts(new Date());
|
||||
const raw = parts.find(p => p.type === 'timeZoneName')?.value || '';
|
||||
// Intl returns 'GMT+02:00' for offsets and 'GMT' for UTC. Normalize.
|
||||
if (raw === 'GMT' || raw === '') return 'UTC';
|
||||
return raw.replace(/^GMT/, 'UTC');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function _friendlyCityFromIana(iana: string): string {
|
||||
const last = iana.split('/').pop() || iana;
|
||||
return last.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function _regionLabel(region: RegionKey): string {
|
||||
return t(`common.daylight_tz.region.${region}`) || REGION_FALLBACK[region];
|
||||
}
|
||||
|
||||
function _formatRowLabel(city: string, offset: string): string {
|
||||
// Em-dash separator, monospaced offset rendered via CSS on the popup.
|
||||
return offset ? `${city} · ${offset}` : city;
|
||||
}
|
||||
|
||||
/** Build the list of items for an EntitySelect timezone picker. */
|
||||
export function getTimezoneItems(): IconSelectItem[] {
|
||||
const items: IconSelectItem[] = [];
|
||||
const detected = _detectedTimezone();
|
||||
|
||||
items.push({
|
||||
value: '',
|
||||
icon: ICON_TARGET_ICON,
|
||||
label: t('common.daylight_tz.system') || 'System default',
|
||||
desc: t('common.daylight_tz.system_desc') || 'Server clock',
|
||||
});
|
||||
|
||||
if (detected) {
|
||||
const offset = _utcOffsetLabel(detected);
|
||||
const city = _friendlyCityFromIana(detected);
|
||||
const detectedLabel = t('common.daylight_tz.detected_label') || 'Auto-detected';
|
||||
items.push({
|
||||
value: detected,
|
||||
icon: ICON_SPARKLES,
|
||||
label: _formatRowLabel(city, offset),
|
||||
desc: `${detectedLabel} · ${detected}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const tz of TIMEZONES) {
|
||||
if (detected && tz.iana === detected) continue;
|
||||
const offset = _utcOffsetLabel(tz.iana);
|
||||
const region = _regionLabel(tz.region);
|
||||
items.push({
|
||||
value: tz.iana,
|
||||
icon: tz.region === 'utc' ? ICON_CLOCK : ICON_GLOBE,
|
||||
label: _formatRowLabel(tz.city, offset),
|
||||
desc: `${region} · ${tz.iana}`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate a `<select>` with timezone options. Kept for back-compat; the new
|
||||
* preferred entrypoint is `enhanceTimezoneSelect()`.
|
||||
*/
|
||||
export function populateTimezoneSelect(selectId: string, currentValue: string): void {
|
||||
const select = document.getElementById(selectId) as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
_syncNativeOptions(select, currentValue);
|
||||
}
|
||||
|
||||
function _syncNativeOptions(select: HTMLSelectElement, currentValue: string): void {
|
||||
const items = getTimezoneItems();
|
||||
const seen = new Set<string>();
|
||||
const fragments: string[] = [];
|
||||
for (const item of items) {
|
||||
if (seen.has(item.value)) continue;
|
||||
seen.add(item.value);
|
||||
const text = item.desc ? `${item.label} — ${item.desc}` : item.label;
|
||||
fragments.push(`<option value="${_escapeAttr(item.value)}">${_escapeText(text)}</option>`);
|
||||
}
|
||||
if (currentValue && !seen.has(currentValue)) {
|
||||
fragments.unshift(
|
||||
`<option value="${_escapeAttr(currentValue)}">${_escapeText(currentValue)}</option>`,
|
||||
);
|
||||
}
|
||||
select.innerHTML = fragments.join('');
|
||||
select.value = currentValue || '';
|
||||
}
|
||||
|
||||
function _escapeAttr(s: string): string {
|
||||
return s.replace(/[&<>"']/g, c =>
|
||||
c === '&' ? '&'
|
||||
: c === '<' ? '<'
|
||||
: c === '>' ? '>'
|
||||
: c === '"' ? '"'
|
||||
: ''',
|
||||
);
|
||||
}
|
||||
function _escapeText(s: string): string {
|
||||
return _escapeAttr(s);
|
||||
}
|
||||
|
||||
const _enhanced = new WeakMap<HTMLSelectElement, EntitySelect>();
|
||||
|
||||
/**
|
||||
* Replace a native `<select>` with the refined timezone EntitySelect picker.
|
||||
* Idempotent — calling on the same element again refreshes items and value.
|
||||
*/
|
||||
export function enhanceTimezoneSelect(
|
||||
target: HTMLSelectElement,
|
||||
currentValue: string,
|
||||
onChange?: (value: string) => void,
|
||||
): EntitySelect {
|
||||
_syncNativeOptions(target, currentValue);
|
||||
|
||||
const existing = _enhanced.get(target);
|
||||
if (existing) {
|
||||
existing.refresh();
|
||||
existing.setValue(currentValue || '');
|
||||
return existing;
|
||||
}
|
||||
|
||||
const trigger = target.parentElement?.classList.contains('tz-picker-wrap');
|
||||
if (trigger) {
|
||||
// Hint for stylesheet: distinguish the timezone trigger from generic ones.
|
||||
target.parentElement!.dataset.tzPicker = '';
|
||||
}
|
||||
|
||||
const es = new EntitySelect({
|
||||
target,
|
||||
getItems: getTimezoneItems,
|
||||
placeholder: t('common.daylight_tz.search') || 'Search timezones…',
|
||||
onChange,
|
||||
});
|
||||
_enhanced.set(target, es);
|
||||
return es;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ const KIND_ICONS = {
|
||||
// ── Subtype-specific icon overrides ──
|
||||
const SUBTYPE_ICONS = {
|
||||
color_strip_source: {
|
||||
picture_advanced: P.monitor, static: P.palette, color_cycle: P.refreshCw,
|
||||
picture_advanced: P.monitor, static: P.palette,
|
||||
gradient: P.rainbow, effect: P.zap, composite: P.link,
|
||||
mapped: P.mapPin, mapped_zones: P.mapPin,
|
||||
audio: P.music, audio_visualization: P.music,
|
||||
|
||||
@@ -98,12 +98,14 @@ export function changeLocale() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build locale items for the IconSelect. Uses 2-letter code badge as icon. */
|
||||
/** Build locale items for the IconSelect. The 2-letter code is the only label —
|
||||
* long-form names (English / Русский / 中文) were redundant when the code is
|
||||
* already an unambiguous identifier. Empty `icon` is hidden by CSS via :empty. */
|
||||
function _getLocaleItems(): { value: string; icon: string; label: string }[] {
|
||||
return [
|
||||
{ value: 'en', icon: '<span style="font-weight:700">EN</span>', label: 'English' },
|
||||
{ value: 'ru', icon: '<span style="font-weight:700">RU</span>', label: 'Русский' },
|
||||
{ value: 'zh', icon: '<span style="font-weight:700">ZH</span>', label: '中文' },
|
||||
{ value: 'en', icon: '', label: 'EN' },
|
||||
{ value: 'ru', icon: '', label: 'RU' },
|
||||
{ value: 'zh', icon: '', label: 'ZH' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ export const keyboard = '<path d="M10 8h.01"/><path d="M12 12h.01"/><path d=
|
||||
export const mouse = '<rect x="5" y="2" width="14" height="20" rx="7"/><path d="M12 6v4"/>';
|
||||
export const headphones = '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>';
|
||||
export const trash2 = '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>';
|
||||
export const moreHorizontal = '<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/>';
|
||||
export const listChecks = '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>';
|
||||
export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';
|
||||
export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>';
|
||||
@@ -123,3 +124,13 @@ export const chevronDown = '<path d="m6 9 6 6 6-6"/>';
|
||||
export const plus = '<path d="M5 12h14"/><path d="M12 5v14"/>';
|
||||
// Lucide: git-merge (sequence mode icon)
|
||||
export const gitMerge = '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>';
|
||||
|
||||
// Easing curve glyphs — custom mini-charts that draw the actual curve.
|
||||
// Curve travels from (4, 20) to (20, 4); each path renders the easing
|
||||
// function directly so the picker shows the shape, not a metaphor.
|
||||
export const easingLinear = '<path d="M4 20 20 4"/>';
|
||||
export const easingStep = '<path d="M4 20h8v-8h8V4"/>';
|
||||
export const easingIn = '<path d="M4 20C13 20 16 18 20 4"/>';
|
||||
export const easingOut = '<path d="M4 20C8 6 11 4 20 4"/>';
|
||||
export const easingInOut = '<path d="M4 20C12 20 12 4 20 4"/>';
|
||||
export const easingSine = '<path d="M4 12C7 12 8 4 12 4S17 12 20 12"/>';
|
||||
|
||||
@@ -87,6 +87,19 @@ export class IconSelect {
|
||||
this._searchable = searchable;
|
||||
this._searchPlaceholder = searchPlaceholder;
|
||||
|
||||
// Ensure the native select has an <option> for every item so .value can
|
||||
// be set programmatically. HTML-authored selects sometimes ship empty,
|
||||
// and assigning .value to a select with no matching option is a no-op,
|
||||
// which leaves the trigger label blank.
|
||||
if (this._select.options.length === 0) {
|
||||
for (const item of this._items) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.value;
|
||||
opt.textContent = item.label;
|
||||
this._select.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the native select
|
||||
this._select.style.display = 'none';
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb
|
||||
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) };
|
||||
const _colorStripTypeIcons = {
|
||||
picture_advanced: _svg(P.monitor),
|
||||
static: _svg(P.palette), color_cycle: _svg(P.refreshCw), gradient: _svg(P.rainbow),
|
||||
static: _svg(P.palette), gradient: _svg(P.rainbow),
|
||||
effect: _svg(P.zap), composite: _svg(P.link),
|
||||
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
|
||||
audio: _svg(P.music), audio_visualization: _svg(P.music),
|
||||
@@ -269,6 +269,7 @@ export const ICON_AUDIO_INPUT = _svg(P.mic);
|
||||
export const ICON_CLOCK = _svg(P.clock);
|
||||
export const ICON_WARNING = _svg(P.triangleAlert);
|
||||
export const ICON_OK = _svg(P.circleCheck);
|
||||
export const ICON_CHECK = _svg(P.check);
|
||||
export const ICON_LINK_SOURCE = _svg(P.tv);
|
||||
export const ICON_LED = _svg(P.lightbulb);
|
||||
export const ICON_FPS = _svg(P.zap);
|
||||
@@ -318,6 +319,7 @@ export const ICON_FAST_FORWARD = _svg(P.fastForward);
|
||||
export const ICON_ROTATE_CW = _svg(P.rotateCw);
|
||||
export const ICON_ROTATE_CCW = _svg(P.rotateCcw);
|
||||
export const ICON_DOWNLOAD = _svg(P.download);
|
||||
export const ICON_HARD_DRIVE = _svg(P.hardDrive);
|
||||
export const ICON_UNDO = _svg(P.undo2);
|
||||
export const ICON_SCENE = _svg(P.sparkles);
|
||||
export const ICON_CAPTURE = _svg(P.camera);
|
||||
@@ -330,6 +332,7 @@ export const ICON_KEYBOARD = _svg(P.keyboard);
|
||||
export const ICON_MOUSE = _svg(P.mouse);
|
||||
export const ICON_HEADPHONES = _svg(P.headphones);
|
||||
export const ICON_TRASH = _svg(P.trash2);
|
||||
export const ICON_KEBAB = _svg(P.moreHorizontal);
|
||||
export const ICON_LIST_CHECKS = _svg(P.listChecks);
|
||||
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
|
||||
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Mod-card renderers — structured helpers that emit the dashboard's
|
||||
* `.mod-*` markup vocabulary on entity cards.
|
||||
*
|
||||
* Background: dashboard.css already ships the `.mod-head / .mod-id /
|
||||
* .mod-badge / .mod-leds / .mod-metrics / .mod-foot / .mod-patch /
|
||||
* .mod-btn` system. This module makes that same vocabulary callable
|
||||
* from any feature's `create*Card()` builder, so device, target,
|
||||
* automation, source, etc. cards can converge on the same visual
|
||||
* language used by the Dashboard tab.
|
||||
*
|
||||
* Used by the new `wrapCard({ mod })` path in `card-colors.ts`.
|
||||
*/
|
||||
|
||||
import { t } from './i18n.ts';
|
||||
import { escapeHtml } from './api.ts';
|
||||
import { ICON_TRASH, ICON_CLONE, ICON_EYE_OFF, ICON_EYE, ICON_KEBAB } from './icons.ts';
|
||||
import { cardColorDot } from './card-colors.ts';
|
||||
|
||||
export type LedState = 'on' | 'off' | 'blink' | 'fault';
|
||||
|
||||
export interface ModBadgeOpts {
|
||||
/** Mono-caps type label, e.g. "LED · CH-01" or "FFT · IN" */
|
||||
text: string;
|
||||
/** Emit a leading colour-picker dot inside the badge (default true).
|
||||
* Set false for cards that don't carry a personal accent. */
|
||||
colorDot?: boolean;
|
||||
}
|
||||
|
||||
export interface ModMetricOpts {
|
||||
/** Caps label */
|
||||
k: string;
|
||||
/** Display-font value (HTML allowed for <small> / <span> etc.) */
|
||||
v: string;
|
||||
/** Add 'signal' modifier so the value tints to --ch */
|
||||
accent?: boolean;
|
||||
/** Mark the cell as having errors (coral tint) */
|
||||
error?: boolean;
|
||||
/** Tooltip on the cell */
|
||||
title?: string;
|
||||
/** Inline icon next to the label */
|
||||
icon?: string;
|
||||
/** Optional id on the .v element for live updates */
|
||||
valueId?: string;
|
||||
/** Visual variant for the cell — 'text-stack' renders the value in
|
||||
* mono font with optional `<span class="v-sub">` block for a
|
||||
* secondary line (e.g. LED chip + RGB/RGBW). The default display
|
||||
* font is too coarse for string identifiers. */
|
||||
variant?: 'text-stack';
|
||||
/** Extra raw HTML appended after the .v element inside the cell —
|
||||
* used to embed a sparkline canvas (`.mod-metric-spark-canvas`)
|
||||
* into the FPS metric, mirroring the dashboard pattern. */
|
||||
extra?: string;
|
||||
/** Extra data attributes on the .v element (e.g. data-fps-text for
|
||||
* cached lookup during live updates). */
|
||||
valueDataAttrs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ModChipOpts {
|
||||
/** Visible text */
|
||||
text: string;
|
||||
/** Inline icon HTML before the text */
|
||||
icon?: string;
|
||||
/** Click handler — triggers the link variant if set */
|
||||
onclick?: string;
|
||||
/** "tag" (filled), "err" (coral), or "default" */
|
||||
variant?: 'default' | 'tag' | 'err';
|
||||
/** Tooltip */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ModFaderOpts {
|
||||
/** Mono-caps label, e.g. "Bright" */
|
||||
label: string;
|
||||
/** Current value (0..max). Displayed as raw integer. */
|
||||
value: number;
|
||||
/** Maximum value (e.g. 255). Defaults to 100. */
|
||||
max?: number;
|
||||
/** Optional id on the slider for binding */
|
||||
sliderId?: string;
|
||||
/** oninput handler — receives `this.value` */
|
||||
oninput?: string;
|
||||
/** onchange handler — receives `this.value` */
|
||||
onchange?: string;
|
||||
/** Optional data attributes on the slider */
|
||||
dataAttrs?: Record<string, string>;
|
||||
/** Disable the fader */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ModFootOpts {
|
||||
/** Patch indicator state — controls the dot style */
|
||||
patchState?: 'live' | 'standby' | 'offline' | 'idle';
|
||||
/** Patch label — short caps text, e.g. "PATCHED · OUT-1" */
|
||||
patchLabel?: string;
|
||||
/** Primary action button (left of any secondary actions) */
|
||||
primaryAction?: ModBtnOpts;
|
||||
/** Secondary text-button(s) */
|
||||
secondaryActions?: ModBtnOpts[];
|
||||
/** Tertiary icon-only button(s) — settings, edit, etc. */
|
||||
iconActions?: ModBtnOpts[];
|
||||
}
|
||||
|
||||
export interface ModBtnOpts {
|
||||
/** Visible label (omitted for icon-only variant) */
|
||||
label?: string;
|
||||
/** Icon HTML */
|
||||
icon?: string;
|
||||
/** onclick handler */
|
||||
onclick: string;
|
||||
/** Tooltip */
|
||||
title?: string;
|
||||
/** Variant */
|
||||
variant?: 'go' | 'stop' | 'default';
|
||||
/** Make this an icon-only button (.mod-btn-icon) */
|
||||
iconOnly?: boolean;
|
||||
/** i18n keys for runtime translation */
|
||||
i18nTitle?: string;
|
||||
/** Extra data attributes (e.g. for live-update binding) */
|
||||
dataAttrs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ModMenuItemOpts {
|
||||
/** Visible text */
|
||||
label: string;
|
||||
/** Icon HTML */
|
||||
icon?: string;
|
||||
/** Inline onclick. The menu auto-closes on click. */
|
||||
onclick: string;
|
||||
/** Mark as destructive (coral colour, separator above) */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface ModMenuOpts {
|
||||
/** Append a Duplicate item (default true if duplicateOnclick provided) */
|
||||
duplicateOnclick?: string;
|
||||
/** Append a Hide-card item (default true). The handler should toggle
|
||||
* the entity's hidden state via the existing CardSection helpers. */
|
||||
hideOnclick?: string;
|
||||
/** Append a destructive Delete item (with separator above). When
|
||||
* omitted, no Delete option appears (e.g. built-in entities). */
|
||||
deleteOnclick?: string;
|
||||
/** Custom menu items inserted before the standard set */
|
||||
extraItems?: ModMenuItemOpts[];
|
||||
}
|
||||
|
||||
export interface ModHeadOpts {
|
||||
badge: ModBadgeOpts;
|
||||
/** Card title — escaped before rendering */
|
||||
name: string;
|
||||
/** Optional secondary line under the title — plain text, escaped.
|
||||
* Use `metaHtml` instead when you need a clickable link or other
|
||||
* inline markup. Mutually exclusive with `metaHtml`. */
|
||||
meta?: string;
|
||||
/** Raw HTML version of `meta`. Caller is responsible for escaping
|
||||
* any user-controlled substrings before passing in. */
|
||||
metaHtml?: string;
|
||||
/** 0–3 LEDs in the recessed bezel. Hidden when empty. */
|
||||
leds?: LedState[];
|
||||
/** Health dot to inject inline beside the name (raw HTML) */
|
||||
healthDot?: string;
|
||||
/** Overflow menu options. Pass null to suppress the menu entirely
|
||||
* (e.g. for read-only or system cards). */
|
||||
menu?: ModMenuOpts | null;
|
||||
/** Entity id — required when colorDot is true so the picker can
|
||||
* register against it. */
|
||||
entityId?: string;
|
||||
/** Data-attr name (e.g. 'data-device-id') used by the picker to
|
||||
* propagate the chosen colour to every card representing this
|
||||
* entity. */
|
||||
cardAttr?: string;
|
||||
}
|
||||
|
||||
export interface ModBodyOpts {
|
||||
metrics?: ModMetricOpts[];
|
||||
chips?: ModChipOpts[];
|
||||
fader?: ModFaderOpts;
|
||||
/** Raw HTML for a preview surface (gradient strip, asset thumb, LED preview) */
|
||||
preview?: string;
|
||||
/** Free-form description text shown below the head */
|
||||
desc?: string;
|
||||
/** Free-form raw HTML appended at the end of the body — escape-hatch
|
||||
* for live-updating widgets (sparkline canvases, swatch grids,
|
||||
* collapsible detail blocks) that don't fit the predefined slots.
|
||||
* Caller is responsible for HTML-escaping any user-controlled
|
||||
* substrings. */
|
||||
extraHtml?: string;
|
||||
}
|
||||
|
||||
export interface ModCardOpts {
|
||||
head: ModHeadOpts;
|
||||
body?: ModBodyOpts;
|
||||
foot?: ModFootOpts;
|
||||
/** Mark the card as `is-running` */
|
||||
running?: boolean;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Renderers
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function _ledsHtml(leds?: LedState[]): string {
|
||||
if (!leds || leds.length === 0) return '';
|
||||
const dots = leds.map(s => {
|
||||
if (s === 'off') return '<span class="led"></span>';
|
||||
if (s === 'fault') return '<span class="led fault"></span>';
|
||||
if (s === 'blink') return '<span class="led on blink"></span>';
|
||||
return '<span class="led on"></span>';
|
||||
}).join('');
|
||||
return `<div class="mod-leds" aria-hidden="true">${dots}</div>`;
|
||||
}
|
||||
|
||||
function _menuHtml(menu: ModMenuOpts | null | undefined): string {
|
||||
if (menu === null) return '';
|
||||
const m = menu || {};
|
||||
|
||||
const items: string[] = [];
|
||||
|
||||
if (m.extraItems) {
|
||||
for (const it of m.extraItems) {
|
||||
const cls = it.danger ? 'mod-menu__item mod-menu__item--danger' : 'mod-menu__item';
|
||||
items.push(`<button type="button" class="${cls}" role="menuitem" onclick="${it.onclick}">${it.icon || ''} <span>${escapeHtml(it.label)}</span></button>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (m.duplicateOnclick) {
|
||||
items.push(`<button type="button" class="mod-menu__item" role="menuitem" onclick="${m.duplicateOnclick}">${ICON_CLONE} <span>${t('common.clone')}</span></button>`);
|
||||
}
|
||||
|
||||
if (m.hideOnclick) {
|
||||
items.push(`<button type="button" class="mod-menu__item" role="menuitem" onclick="${m.hideOnclick}">${ICON_EYE_OFF} <span>${t('common.hide') || 'Hide'}</span></button>`);
|
||||
}
|
||||
|
||||
if (m.deleteOnclick) {
|
||||
if (items.length > 0) items.push('<div class="mod-menu__sep"></div>');
|
||||
items.push(`<button type="button" class="mod-menu__item mod-menu__item--danger" role="menuitem" onclick="${m.deleteOnclick}">${ICON_TRASH} <span>${t('common.delete')}</span></button>`);
|
||||
}
|
||||
|
||||
if (items.length === 0) return '';
|
||||
|
||||
return `<div class="mod-menu-wrap">
|
||||
<button type="button" class="mod-menu-btn" aria-haspopup="true" aria-expanded="false" aria-label="${t('common.more_actions') || 'More actions'}" title="${t('common.more_actions') || 'More actions'}">${ICON_KEBAB}</button>
|
||||
<div class="mod-menu" role="menu">${items.join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _badgeHtml(badge: ModBadgeOpts, entityId?: string, cardAttr?: string): string {
|
||||
const showDot = badge.colorDot !== false && entityId && cardAttr;
|
||||
const dot = showDot ? cardColorDot(entityId!, cardAttr!) : '';
|
||||
return `<span class="mod-badge">${dot}${escapeHtml(badge.text)}</span>`;
|
||||
}
|
||||
|
||||
export function renderModHead(head: ModHeadOpts): string {
|
||||
const badgeHtml = _badgeHtml(head.badge, head.entityId, head.cardAttr);
|
||||
const nameHtml = `<div class="mod-name"><span>${escapeHtml(head.name)}</span>${head.healthDot || ''}</div>`;
|
||||
const metaHtml = head.metaHtml ? `<div class="mod-meta">${head.metaHtml}</div>`
|
||||
: head.meta ? `<div class="mod-meta">${escapeHtml(head.meta)}</div>`
|
||||
: '';
|
||||
const ledsHtml = _ledsHtml(head.leds);
|
||||
const menuHtml = _menuHtml(head.menu);
|
||||
|
||||
// Order: id (flex:1) → kebab → LED bezel. LED status is the
|
||||
// running/idle indicator and lives at the far-right corner where
|
||||
// it doubles as the visual anchor of the head row. Kebab sits to
|
||||
// its left as the second-most-discreet element.
|
||||
return `<div class="mod-head">
|
||||
<div class="mod-id">
|
||||
${badgeHtml}
|
||||
${nameHtml}
|
||||
${metaHtml}
|
||||
</div>
|
||||
${menuHtml}
|
||||
${ledsHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderModMetrics(metrics: ModMetricOpts[]): string {
|
||||
if (!metrics.length) return '';
|
||||
const cellsClass = metrics.length === 1 ? 'mod-metrics mod-metrics--1'
|
||||
: metrics.length === 2 ? 'mod-metrics mod-metrics--2'
|
||||
: 'mod-metrics';
|
||||
const cells = metrics.map(m => {
|
||||
const cellCls = [
|
||||
'mod-metric',
|
||||
m.error ? 'has-errors' : '',
|
||||
m.variant ? `mod-metric--${m.variant}` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const titleAttr = m.title ? ` title="${escapeHtml(m.title)}"` : '';
|
||||
const vCls = ['v', m.accent ? 'signal' : '', m.error ? 'has-errors' : ''].filter(Boolean).join(' ');
|
||||
const vIdAttr = m.valueId ? ` id="${m.valueId}"` : '';
|
||||
const vDataAttrs = m.valueDataAttrs
|
||||
? ' ' + Object.entries(m.valueDataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
|
||||
: '';
|
||||
const labelHtml = `<span class="k">${m.icon || ''} <span>${escapeHtml(m.k)}</span></span>`;
|
||||
const extraHtml = m.extra || '';
|
||||
return `<div class="${cellCls}"${titleAttr}>${labelHtml}<span class="${vCls}"${vIdAttr}${vDataAttrs}>${m.v}</span>${extraHtml}</div>`;
|
||||
}).join('');
|
||||
return `<div class="${cellsClass}">${cells}</div>`;
|
||||
}
|
||||
|
||||
export function renderModChips(chips: ModChipOpts[]): string {
|
||||
if (!chips.length) return '';
|
||||
const items = chips.map(c => {
|
||||
const variant = c.variant === 'tag' ? ' chip--tag'
|
||||
: c.variant === 'err' ? ' chip--err'
|
||||
: '';
|
||||
const link = c.onclick ? ' chip--link' : '';
|
||||
const titleAttr = c.title ? ` title="${escapeHtml(c.title)}"` : '';
|
||||
const onclickAttr = c.onclick ? ` onclick="${c.onclick}"` : '';
|
||||
return `<span class="chip${variant}${link}"${titleAttr}${onclickAttr}>${c.icon || ''} ${escapeHtml(c.text)}</span>`;
|
||||
}).join('');
|
||||
return `<div class="mod-chips">${items}</div>`;
|
||||
}
|
||||
|
||||
export function renderModFader(f: ModFaderOpts): string {
|
||||
const max = f.max || 100;
|
||||
const pct = Math.round(Math.max(0, Math.min(1, f.value / max)) * 100);
|
||||
const sliderId = f.sliderId ? ` id="${f.sliderId}"` : '';
|
||||
const dataAttrs = f.dataAttrs
|
||||
? Object.entries(f.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
|
||||
: '';
|
||||
const oninputAttr = f.oninput ? ` oninput="${f.oninput}"` : '';
|
||||
const onchangeAttr = f.onchange ? ` onchange="${f.onchange}"` : '';
|
||||
const disabledAttr = f.disabled ? ' disabled' : '';
|
||||
return `<div class="mod-fader">
|
||||
<span class="mod-fader__k">${escapeHtml(f.label)}</span>
|
||||
<div class="mod-fader__track"><div class="mod-fader__fill" style="width:${pct}%"></div></div>
|
||||
<input type="range" class="mod-fader__slider" min="0" max="${max}" value="${f.value}"${sliderId} ${dataAttrs}${oninputAttr}${onchangeAttr}${disabledAttr}>
|
||||
<span class="mod-fader__v">${f.value}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _btnHtml(b: ModBtnOpts): string {
|
||||
const variant = b.variant === 'go' ? ' mod-btn-go'
|
||||
: b.variant === 'stop' ? ' mod-btn-stop'
|
||||
: '';
|
||||
const icon = b.iconOnly ? ' mod-btn-icon' : '';
|
||||
const titleAttr = b.title ? ` title="${escapeHtml(b.title)}"` : '';
|
||||
const i18nAttr = b.i18nTitle ? ` data-i18n-title="${b.i18nTitle}"` : '';
|
||||
const labelHtml = b.label ? ` <span>${escapeHtml(b.label)}</span>` : '';
|
||||
const dataAttrs = b.dataAttrs
|
||||
? ' ' + Object.entries(b.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
|
||||
: '';
|
||||
return `<button type="button" class="mod-btn${variant}${icon}" onclick="${b.onclick}"${titleAttr}${i18nAttr}${dataAttrs}>${b.icon || ''}${labelHtml}</button>`;
|
||||
}
|
||||
|
||||
export function renderModFoot(foot: ModFootOpts): string {
|
||||
const liveCls = foot.patchState === 'live' ? ' is-live' : '';
|
||||
const dimCls = foot.patchState === 'offline' ? ' is-offline' : '';
|
||||
const patchHtml = foot.patchLabel
|
||||
? `<div class="mod-patch"><span class="patch-dot${liveCls}${dimCls}"></span><span>${escapeHtml(foot.patchLabel)}</span></div>`
|
||||
: '';
|
||||
|
||||
const buttons: string[] = [];
|
||||
if (foot.primaryAction) buttons.push(_btnHtml(foot.primaryAction));
|
||||
if (foot.secondaryActions) {
|
||||
for (const b of foot.secondaryActions) buttons.push(_btnHtml(b));
|
||||
}
|
||||
if (foot.iconActions) {
|
||||
for (const b of foot.iconActions) buttons.push(_btnHtml({ ...b, iconOnly: true }));
|
||||
}
|
||||
|
||||
return `<div class="mod-foot">${patchHtml}${buttons.join('')}</div>`;
|
||||
}
|
||||
|
||||
export function renderModBody(body: ModBodyOpts | undefined): string {
|
||||
if (!body) return '';
|
||||
const parts: string[] = [];
|
||||
if (body.desc) parts.push(`<div class="mod-desc">${escapeHtml(body.desc)}</div>`);
|
||||
if (body.metrics) parts.push(renderModMetrics(body.metrics));
|
||||
if (body.preview) parts.push(`<div class="mod-preview">${body.preview}</div>`);
|
||||
if (body.chips) parts.push(renderModChips(body.chips));
|
||||
if (body.fader) parts.push(renderModFader(body.fader));
|
||||
if (body.extraHtml) parts.push(body.extraHtml);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a full mod-card body (head + optional body + foot).
|
||||
* Used by `wrapCard({ mod })` — callers normally don't invoke this
|
||||
* directly.
|
||||
*/
|
||||
export function renderModCardInner(opts: ModCardOpts): string {
|
||||
return [
|
||||
renderModHead(opts.head),
|
||||
renderModBody(opts.body),
|
||||
opts.foot ? renderModFoot(opts.foot) : '',
|
||||
].filter(Boolean).join('');
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Mod-card overflow menu — single document-level handler that opens
|
||||
* the kebab dropdown on entity cards that adopt the `.mod-*` markup.
|
||||
*
|
||||
* The menu portal-detaches to <body> when opened so that:
|
||||
* - the card's `overflow: hidden` doesn't clip the dropdown,
|
||||
* - and a hovered card's `transform: translateY(-2px)` doesn't turn
|
||||
* `position: fixed` into "positioned relative to the card" (a
|
||||
* well-known browser behaviour where `position: fixed` is rooted
|
||||
* in the nearest transformed ancestor instead of the viewport).
|
||||
*
|
||||
* On close, the menu is re-inserted at its original DOM position so
|
||||
* subsequent reconciliations keep markup stable.
|
||||
*
|
||||
* Markup (emitted by `renderModMenu` in `mod-card.ts`):
|
||||
*
|
||||
* <div class="mod-menu-wrap">
|
||||
* <button class="mod-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">⋯</button>
|
||||
* <div class="mod-menu" role="menu">
|
||||
* <button class="mod-menu__item" data-action="duplicate">…</button>
|
||||
* <button class="mod-menu__item" data-action="hide">…</button>
|
||||
* <div class="mod-menu__sep"></div>
|
||||
* <button class="mod-menu__item mod-menu__item--danger" data-action="delete">…</button>
|
||||
* </div>
|
||||
* </div>
|
||||
*
|
||||
* Action handlers are attached via inline onclick on the menu items
|
||||
* themselves (matching the rest of the codebase's pattern).
|
||||
*
|
||||
* Initialised once from app.ts via `initModMenu()`.
|
||||
*/
|
||||
|
||||
let _initialised = false;
|
||||
let _controller: AbortController | null = null;
|
||||
|
||||
const MENU_VERTICAL_GAP = 4;
|
||||
const MENU_VIEWPORT_MARGIN = 12;
|
||||
|
||||
interface OpenMenuInfo {
|
||||
wrap: HTMLElement;
|
||||
origParent: Element;
|
||||
origNext: Node | null;
|
||||
}
|
||||
const _openMenus = new Map<HTMLElement, OpenMenuInfo>();
|
||||
|
||||
function _restoreMenu(menu: HTMLElement, info: OpenMenuInfo): void {
|
||||
menu.classList.remove('is-open');
|
||||
menu.style.top = '';
|
||||
menu.style.right = '';
|
||||
menu.style.left = '';
|
||||
menu.style.transformOrigin = '';
|
||||
info.wrap.classList.remove('is-open');
|
||||
const btn = info.wrap.querySelector('.mod-menu-btn');
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
if (info.origParent.isConnected) {
|
||||
info.origParent.insertBefore(menu, info.origNext);
|
||||
} else {
|
||||
// Card was reconciled away while the menu was open — drop the
|
||||
// detached menu rather than reattach to a dead parent.
|
||||
menu.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function _closeAll(): void {
|
||||
_openMenus.forEach((info, menu) => _restoreMenu(menu, info));
|
||||
_openMenus.clear();
|
||||
}
|
||||
|
||||
function _openMenu(wrap: HTMLElement): void {
|
||||
const btn = wrap.querySelector('.mod-menu-btn') as HTMLElement | null;
|
||||
const menu = wrap.querySelector('.mod-menu') as HTMLElement | null;
|
||||
if (!btn || !menu) return;
|
||||
|
||||
// Portal the menu to <body> so transformed/overflow-clipping
|
||||
// ancestors don't capture or hide it. Track origin so we can
|
||||
// restore on close.
|
||||
const origParent = menu.parentElement!;
|
||||
const origNext = menu.nextSibling;
|
||||
document.body.appendChild(menu);
|
||||
_openMenus.set(menu, { wrap, origParent, origNext });
|
||||
|
||||
wrap.classList.add('is-open');
|
||||
menu.classList.add('is-open');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
|
||||
// Position after a paint so offsetHeight is measurable.
|
||||
requestAnimationFrame(() => _positionMenu(menu, btn));
|
||||
}
|
||||
|
||||
/** Anchor the fixed-position menu to the kebab button. Opens downward
|
||||
* by default; flips upward when there isn't enough room below the
|
||||
* trigger (e.g. cards near the viewport bottom). */
|
||||
function _positionMenu(menu: HTMLElement, btn: HTMLElement): void {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const menuH = menu.offsetHeight || 140;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
|
||||
if (spaceBelow < menuH + MENU_VIEWPORT_MARGIN) {
|
||||
// Open upward
|
||||
menu.style.top = `${rect.top - menuH - MENU_VERTICAL_GAP}px`;
|
||||
menu.style.transformOrigin = 'bottom right';
|
||||
} else {
|
||||
menu.style.top = `${rect.bottom + MENU_VERTICAL_GAP}px`;
|
||||
menu.style.transformOrigin = 'top right';
|
||||
}
|
||||
menu.style.right = `${window.innerWidth - rect.right}px`;
|
||||
menu.style.left = 'auto';
|
||||
}
|
||||
|
||||
function _onClick(e: MouseEvent): void {
|
||||
const target = e.target as HTMLElement;
|
||||
const btn = target.closest('.mod-menu-btn') as HTMLElement | null;
|
||||
if (btn) {
|
||||
e.stopPropagation();
|
||||
const wrap = btn.closest('.mod-menu-wrap') as HTMLElement | null;
|
||||
if (!wrap) return;
|
||||
const wasOpen = wrap.classList.contains('is-open');
|
||||
_closeAll();
|
||||
if (!wasOpen) _openMenu(wrap);
|
||||
return;
|
||||
}
|
||||
|
||||
// A click on a menu item: let the inline onclick fire, then close.
|
||||
const item = target.closest('.mod-menu__item') as HTMLElement | null;
|
||||
if (item) {
|
||||
// Use a short timeout so the inline onclick (e.g. a confirm prompt)
|
||||
// executes before the menu disappears under the user's pointer.
|
||||
setTimeout(_closeAll, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Click outside any menu — close all.
|
||||
if (!target.closest('.mod-menu')) _closeAll();
|
||||
}
|
||||
|
||||
function _onKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') _closeAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the mod-menu system once at app boot.
|
||||
* Idempotent — calling more than once is a no-op.
|
||||
*/
|
||||
export function initModMenu(): void {
|
||||
if (_initialised) return;
|
||||
_initialised = true;
|
||||
_controller = new AbortController();
|
||||
const { signal } = _controller;
|
||||
document.addEventListener('click', _onClick, { signal });
|
||||
document.addEventListener('keydown', _onKeydown, { signal });
|
||||
// Close on scroll/resize so the fixed-position menu doesn't drift
|
||||
// away from its anchor.
|
||||
window.addEventListener('scroll', _closeAll, { passive: true, capture: true, signal });
|
||||
window.addEventListener('resize', _closeAll, { passive: true, signal });
|
||||
}
|
||||
|
||||
/** Tear down listeners (used by tests / hot reload). */
|
||||
export function disposeModMenu(): void {
|
||||
if (_controller) {
|
||||
_controller.abort();
|
||||
_controller = null;
|
||||
}
|
||||
_initialised = false;
|
||||
_closeAll();
|
||||
}
|
||||
|
||||
/** Manually close any open menu — exposed for callers that need to
|
||||
* dismiss a menu after a programmatic action (e.g. a confirm dialog
|
||||
* closing). */
|
||||
export function closeAllModMenus(): void {
|
||||
_closeAll();
|
||||
}
|
||||
@@ -29,13 +29,21 @@ export class Modal {
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this.el?.style.display === 'flex';
|
||||
// While the exit animation runs, display is still 'flex' but the modal
|
||||
// is non-interactive — treat it as closed.
|
||||
return this.el?.style.display === 'flex' && !this.el?.classList.contains('closing');
|
||||
}
|
||||
|
||||
open() {
|
||||
this._previousFocus = document.activeElement;
|
||||
// If a close animation is in flight, we still own the body lock — it
|
||||
// gets released in finalize(). Skip the lockBody() call in that case
|
||||
// so the lock count stays balanced.
|
||||
const hadInFlightClose = this._exitCleanup !== null;
|
||||
this.el!.classList.remove('closing');
|
||||
this._cancelExitAnim();
|
||||
this.el!.style.display = 'flex';
|
||||
if (this._lock) lockBody();
|
||||
if (this._lock && !hadInFlightClose) lockBody();
|
||||
if (this._backdrop) setupBackdropClose(this.el!, () => this.close());
|
||||
trapFocus(this.el!);
|
||||
Modal._stack = Modal._stack.filter(m => m !== this);
|
||||
@@ -51,18 +59,75 @@ export class Modal {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pending exit-animation cleanup, so a re-open during the animation can cancel it. */
|
||||
_exitCleanup: (() => void) | null = null;
|
||||
|
||||
_cancelExitAnim() {
|
||||
if (this._exitCleanup) {
|
||||
this._exitCleanup();
|
||||
this._exitCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
forceClose() {
|
||||
releaseFocus(this.el!);
|
||||
this.el!.style.display = 'none';
|
||||
if (this._lock) unlockBody();
|
||||
if (!this.el) return;
|
||||
// Already closing → don't restart the animation.
|
||||
if (this.el.classList.contains('closing')) return;
|
||||
|
||||
// Modal-state cleanup happens immediately so subsequent code that
|
||||
// queries Modal._stack / focus sees a "closed" state. Body-lock
|
||||
// release AND subclass cleanup (onForceClose) are deferred to
|
||||
// finalize() — running them now would toggle html.modal-open or
|
||||
// mutate DOM (widget destruction clears innerHTML) before the exit
|
||||
// animation, causing a visible layout shift inside the modal.
|
||||
releaseFocus(this.el);
|
||||
this._initialValues = {};
|
||||
this.hideError();
|
||||
this.onForceClose();
|
||||
Modal._stack = Modal._stack.filter(m => m !== this);
|
||||
if (this._previousFocus && typeof (this._previousFocus as HTMLElement).focus === 'function') {
|
||||
(this._previousFocus as HTMLElement).focus({ preventScroll: true });
|
||||
this._previousFocus = null;
|
||||
}
|
||||
|
||||
// Run the exit animation, then hide. The animation is owned by the
|
||||
// .modal-content child (the visually larger movement); we listen for
|
||||
// its `animationend` and fall back to a timeout in case the user has
|
||||
// prefers-reduced-motion or the element is detached mid-flight.
|
||||
const el = this.el;
|
||||
const content = el.querySelector('.modal-content') as HTMLElement | null;
|
||||
const EXIT_MS = 220;
|
||||
|
||||
const finalize = () => {
|
||||
this._exitCleanup = null;
|
||||
// Guard against re-open: if open() was called during animation,
|
||||
// the .closing class is gone and display is already 'flex' — the
|
||||
// body lock is still held by the re-opened modal, so don't release.
|
||||
if (!el.classList.contains('closing')) return;
|
||||
el.classList.remove('closing');
|
||||
el.style.display = 'none';
|
||||
this.onForceClose();
|
||||
if (this._lock) unlockBody();
|
||||
};
|
||||
|
||||
let timer: number | null = null;
|
||||
const onEnd = () => {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||
content?.removeEventListener('animationend', onEnd);
|
||||
finalize();
|
||||
};
|
||||
this._exitCleanup = () => {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||
content?.removeEventListener('animationend', onEnd);
|
||||
// Re-open during animation: still run subclass cleanup so the
|
||||
// fresh open re-initializes from a clean slate (widgets etc.).
|
||||
this.onForceClose();
|
||||
};
|
||||
|
||||
el.classList.add('closing');
|
||||
if (content) {
|
||||
content.addEventListener('animationend', onEnd, { once: false });
|
||||
}
|
||||
timer = window.setTimeout(onEnd, EXIT_MS + 80);
|
||||
}
|
||||
|
||||
async close() {
|
||||
@@ -102,7 +167,12 @@ export class Modal {
|
||||
if (this.errorEl) this.errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
/** Hook for subclass cleanup on force-close (canvas, observers, etc.). */
|
||||
/**
|
||||
* Hook for subclass cleanup on force-close (canvas, observers, widget
|
||||
* .destroy() calls, etc.). Runs AFTER the exit animation completes (or
|
||||
* when an in-flight close is canceled by reopen), so DOM mutations
|
||||
* here do not cause a layout shift visible during the animation.
|
||||
*/
|
||||
onForceClose() {}
|
||||
|
||||
$(id: string) {
|
||||
|
||||
@@ -142,14 +142,35 @@ export function set_targetEditorDevices(v: Device[]) { _targetEditorDevices = v;
|
||||
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
|
||||
|
||||
// Tutorial state
|
||||
export interface TutorialStepShape {
|
||||
selector: string;
|
||||
textKey: string;
|
||||
position: string;
|
||||
global?: boolean;
|
||||
/** Optional sub-tab to switch to before highlighting. Lets the
|
||||
* framework reveal panels that live behind tab switches. */
|
||||
subTab?: string;
|
||||
}
|
||||
export interface TutorialState {
|
||||
steps: { selector: string; textKey: string; position: string; global?: boolean }[];
|
||||
steps: TutorialStepShape[];
|
||||
overlay: HTMLElement;
|
||||
mode: string;
|
||||
step: number;
|
||||
resolveTarget: (step: { selector: string; textKey: string; position: string; global?: boolean }) => Element | null;
|
||||
resolveTarget: (step: TutorialStepShape) => Element | null;
|
||||
container: Element | null;
|
||||
onClose: (() => void) | null;
|
||||
/** Called with `step.subTab` before each step. Lets the tour
|
||||
* reveal panels by switching the relevant sub-tab. */
|
||||
switchSubTab: ((key: string) => void) | null;
|
||||
/** CSS selector pointing at an element whose `textContent` is
|
||||
* the current sub-tab label (e.g. `.tree-dd-trigger-title`).
|
||||
* Rendered into the tooltip header as a breadcrumb so the user
|
||||
* knows which panel is being highlighted. */
|
||||
breadcrumbSelector: string | null;
|
||||
/** Called with the current step before resolving the target.
|
||||
* Lets a tour open/close auxiliary UI (e.g. side panels, popups)
|
||||
* so steps inside that UI can be highlighted. */
|
||||
prepare: ((step: TutorialStepShape) => void) | null;
|
||||
}
|
||||
export let activeTutorial: TutorialState | null = null;
|
||||
export function setActiveTutorial(v: TutorialState | null) { activeTutorial = v; }
|
||||
@@ -269,6 +290,12 @@ export const captureTemplatesCache = new DataCache<CaptureTemplate[]>({
|
||||
});
|
||||
captureTemplatesCache.subscribe(v => { _cachedCaptureTemplates = v; });
|
||||
|
||||
export const enginesCache = new DataCache<EngineInfo[]>({
|
||||
endpoint: '/capture-engines',
|
||||
extractData: json => json.engines || [],
|
||||
});
|
||||
enginesCache.subscribe(v => { availableEngines = v; });
|
||||
|
||||
export const audioSourcesCache = new DataCache<AudioSource[]>({
|
||||
endpoint: '/audio-sources',
|
||||
extractData: json => json.sources || [],
|
||||
|
||||
@@ -16,14 +16,136 @@ export function desktopFocus(el: HTMLElement | null) {
|
||||
if (el && !isTouchDevice()) el.focus();
|
||||
}
|
||||
|
||||
export function toggleHint(btn: HTMLElement) {
|
||||
const hint = btn.closest('.label-row')!.nextElementSibling as HTMLElement | null;
|
||||
if (hint && hint.classList.contains('input-hint')) {
|
||||
const visible = hint.style.display !== 'none';
|
||||
hint.style.display = visible ? 'none' : 'block';
|
||||
btn.classList.toggle('active', !visible);
|
||||
btn.setAttribute('aria-expanded', String(!visible));
|
||||
/* ── Hint popover ─────────────────────────────────────────────────
|
||||
The legacy implementation toggled the inline `<small class="input-hint">`
|
||||
between display:none and display:block. That worked but pushed every
|
||||
field below it down — every help click reflowed half the modal. The
|
||||
popover variant anchors a floating tooltip to the `?` button so the
|
||||
form layout stays stable. The inline `<small>` is kept in the DOM
|
||||
purely as a translation source: data-i18n still binds to it, and we
|
||||
read its textContent at click time. */
|
||||
|
||||
let _hintPopoverEl: HTMLElement | null = null;
|
||||
let _hintAnchorBtn: HTMLElement | null = null;
|
||||
let _hintDismissBound = false;
|
||||
|
||||
function _ensureHintPopover(): HTMLElement {
|
||||
if (_hintPopoverEl && document.body.contains(_hintPopoverEl)) return _hintPopoverEl;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'hint-popover';
|
||||
el.setAttribute('role', 'tooltip');
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(el);
|
||||
_hintPopoverEl = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
function _closeHintPopover() {
|
||||
if (_hintPopoverEl) {
|
||||
_hintPopoverEl.classList.remove('open');
|
||||
_hintPopoverEl.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
if (_hintAnchorBtn) {
|
||||
_hintAnchorBtn.classList.remove('active');
|
||||
_hintAnchorBtn.setAttribute('aria-expanded', 'false');
|
||||
_hintAnchorBtn = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _positionHintPopover(pop: HTMLElement, anchor: HTMLElement) {
|
||||
// Park at viewport origin and measure natural size while still hidden.
|
||||
pop.style.left = '0px';
|
||||
pop.style.top = '0px';
|
||||
const popRect = pop.getBoundingClientRect();
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
const spaceBelow = window.innerHeight - anchorRect.bottom;
|
||||
const placeAbove = spaceBelow < popRect.height + gap && anchorRect.top > popRect.height + gap;
|
||||
const top = placeAbove
|
||||
? anchorRect.top - popRect.height - gap
|
||||
: anchorRect.bottom + gap;
|
||||
let left = anchorRect.left + anchorRect.width / 2 - popRect.width / 2;
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));
|
||||
pop.style.top = `${top}px`;
|
||||
pop.style.left = `${left}px`;
|
||||
pop.dataset.placement = placeAbove ? 'top' : 'bottom';
|
||||
const rawArrowX = anchorRect.left + anchorRect.width / 2 - left;
|
||||
const arrowX = Math.max(14, Math.min(rawArrowX, popRect.width - 14));
|
||||
pop.style.setProperty('--hint-arrow-x', `${arrowX}px`);
|
||||
}
|
||||
|
||||
function _bindHintDismiss() {
|
||||
if (_hintDismissBound) return;
|
||||
_hintDismissBound = true;
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (!_hintAnchorBtn) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (_hintAnchorBtn === target || _hintAnchorBtn.contains(target)) return;
|
||||
if (_hintPopoverEl && _hintPopoverEl.contains(target)) return;
|
||||
_closeHintPopover();
|
||||
}, true);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && _hintAnchorBtn) {
|
||||
e.stopPropagation();
|
||||
_closeHintPopover();
|
||||
}
|
||||
});
|
||||
// Reposition or close on layout shifts.
|
||||
document.addEventListener('scroll', () => _closeHintPopover(), true);
|
||||
window.addEventListener('resize', () => _closeHintPopover());
|
||||
document.addEventListener('languageChanged', () => _closeHintPopover());
|
||||
// Catch the case where a modal closes programmatically (Save button,
|
||||
// success path) — the modal grows the .closing class which kicks off
|
||||
// the fadeOut animation. Dismiss any anchored popover at the same time
|
||||
// so we don't leave an orphaned tooltip floating over the page.
|
||||
document.addEventListener('animationstart', (e) => {
|
||||
if (!_hintAnchorBtn) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.classList?.contains('modal') && target.classList.contains('closing')) {
|
||||
_closeHintPopover();
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
export function toggleHint(btn: HTMLElement) {
|
||||
// Look for the .input-hint either as the immediate next sibling of the
|
||||
// .label-row (form-group pattern) or anywhere else inside the
|
||||
// surrounding .ds-toggle-text / .form-group wrapper. This keeps the
|
||||
// helper working when the hint sits between the label-row and a
|
||||
// status sub-line (e.g. OS Permission row in the settings modal).
|
||||
const labelRow = btn.closest('.label-row') as HTMLElement | null;
|
||||
let hint = labelRow?.nextElementSibling as HTMLElement | null;
|
||||
if (!hint || !hint.classList.contains('input-hint')) {
|
||||
const wrap = btn.closest('.ds-toggle-text, .form-group, .ds-toggle-row') as HTMLElement | null;
|
||||
hint = wrap?.querySelector(':scope > .input-hint') as HTMLElement | null;
|
||||
}
|
||||
if (!hint || !hint.classList.contains('input-hint')) return;
|
||||
|
||||
// Force the legacy inline <small> to stay collapsed — the popover
|
||||
// is now the sole visible surface for hints.
|
||||
hint.style.display = 'none';
|
||||
|
||||
if (_hintAnchorBtn === btn) {
|
||||
_closeHintPopover();
|
||||
return;
|
||||
}
|
||||
|
||||
const text = (hint.textContent || '').trim();
|
||||
if (!text) return;
|
||||
|
||||
// Reuse a single popover so we don't pile up tooltip nodes.
|
||||
_bindHintDismiss();
|
||||
if (_hintAnchorBtn) _closeHintPopover();
|
||||
const pop = _ensureHintPopover();
|
||||
pop.textContent = text;
|
||||
pop.setAttribute('aria-hidden', 'false');
|
||||
_positionHintPopover(pop, btn);
|
||||
pop.classList.add('open');
|
||||
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
_hintAnchorBtn = btn;
|
||||
}
|
||||
|
||||
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
@@ -433,12 +555,15 @@ export function formatCompact(n: number | null | undefined) {
|
||||
return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B';
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number | null | undefined) {
|
||||
export function formatUptime(seconds: number | null | undefined): string {
|
||||
if (!seconds || seconds <= 0) return '-';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return t('time.hours_minutes', { h, m });
|
||||
if (m > 0) return t('time.minutes_seconds', { m, s });
|
||||
return t('time.seconds', { s });
|
||||
const total = Math.floor(seconds);
|
||||
const d = Math.floor(total / 86400);
|
||||
const h = Math.floor((total % 86400) / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
|
||||
return `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
@@ -475,12 +475,15 @@ function _renderLineList(): void {
|
||||
|
||||
function _showLineProps(): void {
|
||||
const propsEl = document.getElementById('advcal-line-props')!;
|
||||
const sectionEl = document.getElementById('advcal-line-props-section');
|
||||
const idx = _state.selectedLine;
|
||||
if (idx < 0 || idx >= _state.lines.length) {
|
||||
propsEl.style.display = 'none';
|
||||
if (sectionEl) sectionEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
propsEl.style.display = '';
|
||||
if (sectionEl) sectionEl.style.display = '';
|
||||
const line = _state.lines[idx];
|
||||
(document.getElementById('advcal-line-source') as HTMLSelectElement).value = line.picture_source_id;
|
||||
if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh();
|
||||
|
||||
@@ -436,15 +436,19 @@ export function initAppearance(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the Appearance tab content. Called when the tab is switched to. */
|
||||
/** Render the Appearance tab content. Called when the tab is switched to.
|
||||
* Each preset grid lives in its own channel-coded .ds-section so the panel
|
||||
* matches the rest of the redesigned settings modal. The .ds-section-meta
|
||||
* pill shows the active preset name in uppercase. */
|
||||
export function renderAppearanceTab(): void {
|
||||
const panel = document.getElementById('settings-panel-appearance');
|
||||
if (!panel) return;
|
||||
|
||||
// Don't re-render if already populated
|
||||
if (panel.querySelector('.appearance-presets')) {
|
||||
// Don't re-render if already populated — just refresh selections + meta pills
|
||||
if (panel.querySelector('.ds-section')) {
|
||||
_updatePresetSelection('style', _activeStyleId);
|
||||
_updatePresetSelection('bg', _activeBgEffectId);
|
||||
_updateAppearanceMetaPills();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -476,18 +480,46 @@ export function renderAppearanceTab(): void {
|
||||
}).join('');
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="appearance-presets">
|
||||
<div class="form-group">
|
||||
<label data-i18n="appearance.style.label">${t('appearance.style.label')}</label>
|
||||
<small class="ap-hint" data-i18n="appearance.style.hint">${t('appearance.style.hint')}</small>
|
||||
<section class="ds-section" data-ch="magenta">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="appearance.style.label">${t('appearance.style.label')}</span>
|
||||
<span class="ds-section-meta" id="appearance-style-meta"></span>
|
||||
<span class="ds-section-index" aria-hidden="true">01</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<small class="input-hint" data-i18n="appearance.style.hint">${t('appearance.style.hint')}</small>
|
||||
<div class="ap-grid">${styleHtml}</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:1rem">
|
||||
<label data-i18n="appearance.bg.label">${t('appearance.bg.label')}</label>
|
||||
<small class="ap-hint" data-i18n="appearance.bg.hint">${t('appearance.bg.hint')}</small>
|
||||
</section>
|
||||
<section class="ds-section" data-ch="cyan">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="appearance.bg.label">${t('appearance.bg.label')}</span>
|
||||
<span class="ds-section-meta" id="appearance-bg-meta"></span>
|
||||
<span class="ds-section-index" aria-hidden="true">02</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<small class="input-hint" data-i18n="appearance.bg.hint">${t('appearance.bg.hint')}</small>
|
||||
<div class="ap-grid">${bgHtml}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
</section>`;
|
||||
|
||||
_updateAppearanceMetaPills();
|
||||
}
|
||||
|
||||
/** Refresh the .ds-section-meta pills to match the active preset names. */
|
||||
function _updateAppearanceMetaPills(): void {
|
||||
const styleMeta = document.getElementById('appearance-style-meta');
|
||||
if (styleMeta) {
|
||||
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId);
|
||||
styleMeta.textContent = preset ? t(preset.nameKey).toUpperCase() : '';
|
||||
}
|
||||
const bgMeta = document.getElementById('appearance-bg-meta');
|
||||
if (bgMeta) {
|
||||
const effect = BG_EFFECT_PRESETS.find(e => e.id === _activeBgEffectId);
|
||||
bgMeta.textContent = effect ? t(effect.nameKey).toUpperCase() : '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the currently active style preset ID. */
|
||||
@@ -549,12 +581,13 @@ function _ensureFont(url: string, id: string): void {
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
/** Update the visual selection ring on preset cards. */
|
||||
/** Update the visual selection ring on preset cards and the meta pill. */
|
||||
function _updatePresetSelection(type: 'style' | 'bg', activeId: string): void {
|
||||
const attr = type === 'style' ? 'style' : 'bg';
|
||||
document.querySelectorAll(`[data-preset-type="${attr}"]`).forEach(el => {
|
||||
el.classList.toggle('active', (el as HTMLElement).dataset.presetId === activeId);
|
||||
});
|
||||
_updateAppearanceMetaPills();
|
||||
}
|
||||
|
||||
// ─── Listen for theme changes to reapply preset colors ──────
|
||||
|
||||
@@ -10,6 +10,7 @@ import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { ICON_CLONE, ICON_EDIT, ICON_DOWNLOAD, ICON_ASSET, ICON_TRASH, getAssetTypeIcon } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import type { Asset } from '../types.ts';
|
||||
@@ -136,39 +137,49 @@ function getAssetTypeLabel(assetType: string): string {
|
||||
// ── Card builder ──
|
||||
|
||||
export function createAssetCard(asset: Asset): string {
|
||||
const icon = getAssetTypeIcon(asset.asset_type);
|
||||
const sizeStr = formatFileSize(asset.size_bytes);
|
||||
const prebuiltBadge = asset.prebuilt
|
||||
? `<span class="stream-card-prop" title="${escapeHtml(t('asset.prebuilt'))}">${_icon(P.star)} ${t('asset.prebuilt')}</span>`
|
||||
: '';
|
||||
const typeLabel = getAssetTypeLabel(asset.asset_type);
|
||||
|
||||
let playBtn = '';
|
||||
if (asset.asset_type === 'sound') {
|
||||
playBtn = `<button class="btn btn-icon btn-secondary" data-action="play" title="${escapeHtml(t('asset.play'))}">${ICON_PLAY_SOUND}</button>`;
|
||||
const badgeText = `ASSET · ${asset.asset_type.slice(0, 3).toUpperCase()}`;
|
||||
const chips: ModChipOpts[] = [
|
||||
{ icon: getAssetTypeIcon(asset.asset_type), text: typeLabel },
|
||||
{ icon: _icon(P.fileText), text: sizeStr },
|
||||
];
|
||||
if (asset.prebuilt) {
|
||||
chips.push({ icon: _icon(P.star), text: t('asset.prebuilt'), title: t('asset.prebuilt') });
|
||||
}
|
||||
|
||||
return wrapCard({
|
||||
dataAttr: 'data-id',
|
||||
id: asset.id,
|
||||
removeOnclick: `deleteAsset('${asset.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="card-header">
|
||||
<div class="card-title" title="${escapeHtml(asset.name)}">
|
||||
${icon} <span class="card-title-text">${escapeHtml(asset.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))}</span>
|
||||
<span class="stream-card-prop">${_icon(P.fileText)} ${sizeStr}</span>
|
||||
${prebuiltBadge}
|
||||
</div>
|
||||
${renderTagChips(asset.tags)}`,
|
||||
actions: `
|
||||
${playBtn}
|
||||
<button class="btn btn-icon btn-secondary" data-action="download" title="${escapeHtml(t('asset.download'))}">${ICON_DOWNLOAD}</button>
|
||||
<button class="btn btn-icon btn-secondary" data-action="edit" title="${escapeHtml(t('common.edit'))}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
const iconActions: any[] = [];
|
||||
if (asset.asset_type === 'sound') {
|
||||
iconActions.push({ icon: ICON_PLAY_SOUND, onclick: '', title: t('asset.play'), dataAttrs: { 'data-action': 'play' } });
|
||||
}
|
||||
iconActions.push({ icon: ICON_DOWNLOAD, onclick: '', title: t('asset.download'), dataAttrs: { 'data-action': 'download' } });
|
||||
iconActions.push({ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } });
|
||||
|
||||
const mod: ModCardOpts = {
|
||||
head: {
|
||||
badge: { text: badgeText },
|
||||
name: asset.name,
|
||||
metaHtml: escapeHtml(`${typeLabel} · ${sizeStr}`),
|
||||
leds: ['on'],
|
||||
menu: {
|
||||
hideOnclick: `toggleCardHidden('assets','${asset.id}')`,
|
||||
deleteOnclick: `deleteAsset('${asset.id}')`,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
chips,
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'READY',
|
||||
iconActions,
|
||||
},
|
||||
};
|
||||
|
||||
const cardHtml = wrapCard({ dataAttr: 'data-id', id: asset.id, mod });
|
||||
const tagsHtml = renderTagChips(asset.tags);
|
||||
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
|
||||
}
|
||||
|
||||
// ── Sound playback ──
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ICON_AUDIO_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { FilterListManager } from '../core/filter-list.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import type { ModCardOpts } from '../core/mod-card.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
|
||||
// ── Module state ─────────────────────────────────────────────
|
||||
@@ -266,34 +267,44 @@ export function renderAPTModalFilterList() { aptFilterManager.render(); }
|
||||
// ── Card rendering (used by streams.ts) ───────────────────────
|
||||
|
||||
export function createAudioProcessingTemplateCard(tmpl: any): string {
|
||||
let filterChainHtml = '';
|
||||
if (tmpl.filters && tmpl.filters.length > 0) {
|
||||
const filterNames = tmpl.filters.map((fi: any) => {
|
||||
const filters = tmpl.filters || [];
|
||||
const chainExtra = filters.length > 0 ? `<div class="filter-chain">${
|
||||
filters.map((fi: any, idx: number) => {
|
||||
let label = _getAudioFilterName(fi.filter_id);
|
||||
if (fi.filter_id === 'audio_filter_template' && fi.options?.template_id) {
|
||||
const ref = _cachedAudioProcessingTemplates.find((p: any) => p.id === fi.options.template_id);
|
||||
if (ref) label += `: ${ref.name}`;
|
||||
}
|
||||
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`;
|
||||
});
|
||||
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
|
||||
}
|
||||
const arrow = idx < filters.length - 1 ? '<span class="filter-chain-arrow">\u2192</span>' : '';
|
||||
return `<span class="filter-chain-item">${escapeHtml(label)}</span>${arrow}`;
|
||||
}).join('')
|
||||
}</div>` : '';
|
||||
|
||||
return wrapCard({
|
||||
type: 'template-card',
|
||||
dataAttr: 'data-apt-id',
|
||||
id: tmpl.id,
|
||||
removeOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="template-card-header">
|
||||
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
|
||||
</div>
|
||||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||||
${filterChainHtml}
|
||||
${renderTagChips(tmpl.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneAudioProcessingTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="editAudioProcessingTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
const mod: ModCardOpts = {
|
||||
head: {
|
||||
badge: { text: 'TPL \u00b7 AUDIO PROC' },
|
||||
name: tmpl.name,
|
||||
metaHtml: escapeHtml(`${filters.length} ${t('audio_processing.title') || 'filters'}`),
|
||||
leds: ['off'],
|
||||
menu: {
|
||||
duplicateOnclick: `cloneAudioProcessingTemplate('${tmpl.id}')`,
|
||||
hideOnclick: `toggleCardHidden('audio-processing-templates','${tmpl.id}')`,
|
||||
deleteOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
desc: tmpl.description || undefined,
|
||||
extraHtml: chainExtra || undefined,
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'PIPELINE',
|
||||
iconActions: [
|
||||
{ icon: ICON_EDIT, onclick: `editAudioProcessingTemplate('${tmpl.id}')`, title: t('common.edit') },
|
||||
],
|
||||
},
|
||||
};
|
||||
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-apt-id', id: tmpl.id, mod });
|
||||
const tagsHtml = renderTagChips(tmpl.tags);
|
||||
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
|
||||
}
|
||||
|
||||
@@ -279,8 +279,15 @@ function _filterDevicesBySelectedTemplate() {
|
||||
const select = document.getElementById('audio-source-device') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
|
||||
// Snapshot current selection BEFORE rebuilding options. We try to
|
||||
// restore it afterwards by:
|
||||
// 1. exact value match — `${index}:${loopback}` — survives a refresh
|
||||
// whenever the OS keeps the same device index (the common case);
|
||||
// 2. name match — survives an OS-side reindex (Windows occasionally
|
||||
// reorders devices) provided the device label is unchanged.
|
||||
const prevValue = select.value;
|
||||
const prevOption = select.options[select.selectedIndex];
|
||||
const prevName = prevOption ? prevOption.textContent : '';
|
||||
const prevName = (prevOption?.textContent ?? '').trim();
|
||||
|
||||
const templateId = ((document.getElementById('audio-source-audio-template') as HTMLSelectElement | null) || { value: '' } as any).value;
|
||||
const templates = _cachedAudioTemplates || [];
|
||||
@@ -305,9 +312,16 @@ function _filterDevicesBySelectedTemplate() {
|
||||
select.innerHTML = '<option value="-1:1">Default</option>';
|
||||
}
|
||||
|
||||
if (prevName) {
|
||||
const match = Array.from(select.options).find((o: HTMLOptionElement) => o.textContent === prevName);
|
||||
if (match) select.value = match.value;
|
||||
const opts = Array.from(select.options) as HTMLOptionElement[];
|
||||
let restored: HTMLOptionElement | undefined;
|
||||
if (prevValue) {
|
||||
restored = opts.find(o => o.value === prevValue);
|
||||
}
|
||||
if (!restored && prevName) {
|
||||
restored = opts.find(o => (o.textContent ?? '').trim() === prevName);
|
||||
}
|
||||
if (restored) {
|
||||
select.value = restored.value;
|
||||
}
|
||||
|
||||
if (_asDeviceEntitySelect) _asDeviceEntitySelect.destroy();
|
||||
@@ -330,7 +344,14 @@ function _selectAudioDevice(deviceIndex: any, isLoopback: any) {
|
||||
if (!select) return;
|
||||
const val = `${deviceIndex ?? -1}:${isLoopback !== false ? '1' : '0'}`;
|
||||
const opt = Array.from(select.options).find((o: HTMLOptionElement) => o.value === val);
|
||||
if (opt) select.value = val;
|
||||
if (opt) {
|
||||
select.value = val;
|
||||
// EntitySelect's trigger button is a separate DOM node populated at
|
||||
// construction time from the select's then-current value. Without
|
||||
// this, the trigger keeps showing the first option even though the
|
||||
// native select already points at the saved device.
|
||||
if (_asDeviceEntitySelect) _asDeviceEntitySelect.setValue(val);
|
||||
}
|
||||
}
|
||||
|
||||
function _loadParentSources(selectedId?: any) {
|
||||
|
||||
@@ -11,9 +11,10 @@ import { Modal } from '../core/modal.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
|
||||
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH } from '../core/icons.ts';
|
||||
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { getBaseOrigin } from './settings.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
@@ -257,96 +258,180 @@ function renderAutomations(automations: any, sceneMap: any) {
|
||||
}
|
||||
}
|
||||
|
||||
type RulePillRenderer = (c: any) => string;
|
||||
type RuleChipBuilder = (c: any) => ModChipOpts;
|
||||
|
||||
const RULE_PILL_RENDERERS: Record<string, RulePillRenderer> = {
|
||||
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.rule.startup')}</span>`,
|
||||
/* Build one chip per automation rule. The chip shows the rule type's
|
||||
icon + a tight, scannable label. Mirrors the AUTO card in the
|
||||
cards-redesign demo: rules read as a left-to-right chain leading into
|
||||
the scene activation. */
|
||||
const RULE_CHIP_RENDERERS: Record<string, RuleChipBuilder> = {
|
||||
startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }),
|
||||
application: (c) => {
|
||||
const apps = (c.apps || []).join(', ');
|
||||
const apps = (c.apps || []).join(', ') || '—';
|
||||
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
|
||||
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.rule.application')}: ${apps} (${matchLabel})</span>`;
|
||||
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
|
||||
},
|
||||
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.rule.time_of_day')}: ${c.start_time || '00:00'} – ${c.end_time || '23:59'}</span>`,
|
||||
time_of_day: (c) => ({
|
||||
icon: ICON_CLOCK,
|
||||
text: `${c.start_time || '00:00'} – ${c.end_time || '23:59'}`,
|
||||
title: t('automations.rule.time_of_day'),
|
||||
}),
|
||||
system_idle: (c) => {
|
||||
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
|
||||
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
|
||||
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
|
||||
},
|
||||
display_state: (c) => {
|
||||
const stateLabel = t('automations.rule.display_state.' + (c.state || 'on'));
|
||||
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.rule.display_state')}: ${stateLabel}</span>`;
|
||||
return { icon: ICON_MONITOR, text: stateLabel, title: t('automations.rule.display_state') };
|
||||
},
|
||||
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.rule.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
|
||||
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.rule.webhook')}</span>`,
|
||||
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.rule.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
|
||||
mqtt: (c) => ({
|
||||
icon: ICON_RADIO,
|
||||
text: `${c.topic || ''} = ${c.payload || '*'}`,
|
||||
title: t('automations.rule.mqtt'),
|
||||
}),
|
||||
webhook: () => ({ icon: ICON_WEB, text: t('automations.rule.webhook') }),
|
||||
home_assistant: (c) => ({
|
||||
icon: _icon(P.home),
|
||||
text: `${c.entity_id || ''} = ${c.state || '*'}`,
|
||||
title: t('automations.rule.home_assistant'),
|
||||
}),
|
||||
};
|
||||
|
||||
/** Render a chain-arrow separator span. `+` between AND-rules,
|
||||
* the localised OR label between OR-rules, and `→` for the
|
||||
* rule-chain → scene-activation transition. */
|
||||
function _chainArrow(glyph: string): string {
|
||||
return `<span class="chain-arrow" aria-hidden="true">${escapeHtml(glyph)}</span>`;
|
||||
}
|
||||
|
||||
/** Render a single chip as the same markup `renderModChips` produces,
|
||||
* so the body chain row reads identically to chip arrays elsewhere
|
||||
* (devices/value sources). Inline build so we can intersperse
|
||||
* chain-arrow separators between chips. */
|
||||
function _chipHtml(c: ModChipOpts): string {
|
||||
const variant = c.variant === 'tag' ? ' chip--tag'
|
||||
: c.variant === 'err' ? ' chip--err'
|
||||
: '';
|
||||
const link = c.onclick ? ' chip--link' : '';
|
||||
const titleAttr = c.title ? ` title="${escapeHtml(c.title)}"` : '';
|
||||
const onclickAttr = c.onclick ? ` onclick="${c.onclick}"` : '';
|
||||
return `<span class="chip${variant}${link}"${titleAttr}${onclickAttr}>${c.icon || ''} ${escapeHtml(c.text)}</span>`;
|
||||
}
|
||||
|
||||
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
||||
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
|
||||
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
|
||||
// ── Rule chips: one per rule, joined by chain-arrow separators
|
||||
// (`+` for AND, `OR` for OR — mirrors the demo's flow language). ──
|
||||
const ruleChips: ModChipOpts[] = automation.rules.length
|
||||
? automation.rules.map(c => {
|
||||
const builder = RULE_CHIP_RENDERERS[c.rule_type];
|
||||
return builder ? builder(c) : { text: c.rule_type };
|
||||
})
|
||||
: [{ text: t('automations.rules.empty') }];
|
||||
|
||||
let rulePills = '';
|
||||
if (automation.rules.length === 0) {
|
||||
rulePills = `<span class="stream-card-prop">${t('automations.rules.empty')}</span>`;
|
||||
} else {
|
||||
const parts = automation.rules.map(c => {
|
||||
const renderer = RULE_PILL_RENDERERS[c.rule_type];
|
||||
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.rule_type}</span>`;
|
||||
});
|
||||
const logicLabel = automation.rule_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
|
||||
rulePills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
|
||||
}
|
||||
const logicGlyph = automation.rule_logic === 'and' ? '+' : 'OR';
|
||||
const ruleChain = ruleChips.map(_chipHtml).join(_chainArrow(logicGlyph));
|
||||
|
||||
// Scene info
|
||||
// ── Scene chip: the action — clickable, navigates to the scene card. ──
|
||||
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
|
||||
const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
|
||||
const sceneColor = scene ? scene.color || '#4fc3f7' : '#888';
|
||||
const sceneChipHtml = _chipHtml(scene ? {
|
||||
icon: ICON_SCENE,
|
||||
text: scene.name,
|
||||
title: t('automations.scene'),
|
||||
onclick: `event.stopPropagation(); navigateToCard('automations','scenes','scenes','data-scene-id','${automation.scene_preset_id}')`,
|
||||
variant: 'tag',
|
||||
} : {
|
||||
icon: ICON_SCENE,
|
||||
text: t('automations.scene.none_selected'),
|
||||
});
|
||||
|
||||
// Deactivation mode label
|
||||
let deactivationMeta = '';
|
||||
// ── Optional deactivation chip — `↩` revert or fallback scene.
|
||||
// Rendered after the scene chip with a chain arrow so the card
|
||||
// reads as: rules → scene ↩ (deactivation behaviour). ──
|
||||
let deactivationHtml = '';
|
||||
if (automation.deactivation_mode === 'revert') {
|
||||
deactivationMeta = `<span class="card-meta">${ICON_UNDO} ${t('automations.deactivation_mode.revert')}</span>`;
|
||||
deactivationHtml = _chainArrow('↩') + _chipHtml({
|
||||
icon: ICON_UNDO,
|
||||
text: t('automations.deactivation_mode.revert'),
|
||||
});
|
||||
} else if (automation.deactivation_mode === 'fallback_scene') {
|
||||
const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null;
|
||||
if (fallback) {
|
||||
const fbColor = fallback.color || '#4fc3f7';
|
||||
deactivationMeta = `<span class="card-meta stream-card-link" onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.deactivation_scene_preset_id}')">${ICON_UNDO} <span style="color:${fbColor}">●</span> ${escapeHtml(fallback.name)}</span>`;
|
||||
} else {
|
||||
deactivationMeta = `<span class="card-meta">${ICON_UNDO} ${t('automations.deactivation_mode.fallback_scene')}</span>`;
|
||||
}
|
||||
deactivationHtml = _chainArrow('↩') + _chipHtml(fallback ? {
|
||||
icon: ICON_UNDO,
|
||||
text: fallback.name,
|
||||
title: t('automations.deactivation_mode.fallback_scene'),
|
||||
onclick: `event.stopPropagation(); navigateToCard('automations','scenes','scenes','data-scene-id','${automation.deactivation_scene_preset_id}')`,
|
||||
} : {
|
||||
icon: ICON_UNDO,
|
||||
text: t('automations.deactivation_mode.fallback_scene'),
|
||||
});
|
||||
}
|
||||
|
||||
let lastActivityMeta = '';
|
||||
const chipsHtml = `<div class="mod-chips">${ruleChain}${_chainArrow('→')}${sceneChipHtml}${deactivationHtml}</div>`;
|
||||
|
||||
// ── State surfaces: LED + patch indicator ──
|
||||
// Active = blink (live signal); Enabled-but-idle = off (waiting);
|
||||
// Disabled = fault (red, indicates unavailable rather than error).
|
||||
const ledState = !automation.enabled ? 'fault'
|
||||
: automation.is_active ? 'blink'
|
||||
: 'off';
|
||||
const patchState = !automation.enabled ? 'offline'
|
||||
: automation.is_active ? 'live'
|
||||
: 'idle';
|
||||
const patchLabel = !automation.enabled ? t('automations.status.disabled').toUpperCase()
|
||||
: automation.is_active ? t('automations.status.active').toUpperCase()
|
||||
: t('automations.status.inactive').toUpperCase();
|
||||
|
||||
// ── Meta: last-fired timestamp only — the rule/scene chain is
|
||||
// already laid out below, so meta stays a quiet single-line
|
||||
// history hint. ──
|
||||
let metaHtml: string | undefined;
|
||||
if (automation.last_activated_at) {
|
||||
const ts = new Date(automation.last_activated_at);
|
||||
lastActivityMeta = `<span class="card-meta" title="${t('automations.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`;
|
||||
metaHtml = `${t('automations.last_activated')} · ${escapeHtml(ts.toLocaleString())}`;
|
||||
}
|
||||
|
||||
return wrapCard({
|
||||
// ── Badge: "AUTO · XX" — short id slice mirrors the demo's
|
||||
// "AUTO · 07" pattern (last 2 hex chars, uppercase). ──
|
||||
const shortId = (automation.id || '').replace(/^auto_/i, '').slice(-2).toUpperCase() || 'NA';
|
||||
|
||||
const mod: ModCardOpts = {
|
||||
running: automation.is_active,
|
||||
head: {
|
||||
badge: { text: `AUTO · ${shortId}` },
|
||||
name: automation.name,
|
||||
metaHtml,
|
||||
leds: [ledState],
|
||||
menu: {
|
||||
duplicateOnclick: `cloneAutomation('${automation.id}')`,
|
||||
hideOnclick: `toggleCardHidden('automations','${automation.id}')`,
|
||||
deleteOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
extraHtml: chipsHtml,
|
||||
},
|
||||
foot: {
|
||||
patchState,
|
||||
patchLabel,
|
||||
secondaryActions: [
|
||||
automation.enabled
|
||||
? { label: t('automations.action.disable'), icon: ICON_PAUSE, onclick: `toggleAutomationEnabled('${automation.id}', false)`, variant: 'stop' }
|
||||
: { label: t('search.action.enable'), icon: ICON_START, onclick: `toggleAutomationEnabled('${automation.id}', true)`, variant: 'go' },
|
||||
],
|
||||
iconActions: [
|
||||
{ icon: ICON_EDIT, onclick: `openAutomationEditor('${automation.id}')`, title: t('automations.edit') },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const cardHtml = wrapCard({
|
||||
dataAttr: 'data-automation-id',
|
||||
id: automation.id,
|
||||
classes: !automation.enabled ? 'automation-status-disabled' : '',
|
||||
removeOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="card-header">
|
||||
<div class="card-title" title="${escapeHtml(automation.name)}">
|
||||
<span class="card-title-text">${escapeHtml(automation.name)}</span>
|
||||
<span class="badge badge-automation-${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-subtitle">
|
||||
<span class="card-meta">${rulePills}</span>
|
||||
<span class="card-meta${scene ? ' stream-card-link' : ''}"${scene ? ` onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.scene_preset_id}')"` : ''}>${ICON_SCENE} <span style="color:${sceneColor}">●</span> ${sceneName}</span>
|
||||
${deactivationMeta}
|
||||
</div>
|
||||
${renderTagChips(automation.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneAutomation('${automation.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button>
|
||||
<button class="btn btn-icon ${automation.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleAutomationEnabled('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
|
||||
${automation.enabled ? ICON_PAUSE : ICON_START}
|
||||
</button>`,
|
||||
mod,
|
||||
});
|
||||
const tagsHtml = renderTagChips(automation.tags);
|
||||
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
|
||||
}
|
||||
|
||||
export async function openAutomationEditor(automationId?: any, cloneData?: any) {
|
||||
|
||||
@@ -55,6 +55,8 @@ class CalibrationModal extends Modal {
|
||||
(document.getElementById('calibration-css-id') as HTMLInputElement).value = '';
|
||||
const testGroup = document.getElementById('calibration-css-test-group');
|
||||
if (testGroup) testGroup.style.display = 'none';
|
||||
const testSection = document.getElementById('calibration-test-setup-section');
|
||||
if (testSection) testSection.style.display = 'none';
|
||||
} else {
|
||||
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
|
||||
if (deviceId) clearTestMode(deviceId);
|
||||
@@ -162,6 +164,8 @@ export async function showCalibration(deviceId: any) {
|
||||
(document.getElementById('calibration-device-id') as HTMLInputElement).value = device.id;
|
||||
(document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = device.led_count;
|
||||
(document.getElementById('cal-css-led-count-group') as HTMLElement).style.display = 'none';
|
||||
const testSectionDevMode = document.getElementById('calibration-test-setup-section');
|
||||
if (testSectionDevMode) testSectionDevMode.style.display = 'none';
|
||||
(document.getElementById('calibration-overlay-btn') as HTMLElement).style.display = 'none';
|
||||
|
||||
(document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position;
|
||||
@@ -262,6 +266,8 @@ export async function showCSSCalibration(cssId: any) {
|
||||
_calTestDeviceList = devices;
|
||||
const testGroup = document.getElementById('calibration-css-test-group') as HTMLElement;
|
||||
testGroup.style.display = devices.length ? '' : 'none';
|
||||
const testSection = document.getElementById('calibration-test-setup-section') as HTMLElement | null;
|
||||
if (testSection) testSection.style.display = '';
|
||||
|
||||
// Pre-select device: 1) LED count match, 2) last remembered, 3) first
|
||||
if (devices.length) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_PATTERN_TEMPLATE,
|
||||
} from '../../core/icons.ts';
|
||||
import { wrapCard } from '../../core/card-colors.ts';
|
||||
import type { ModCardOpts } from '../../core/mod-card.ts';
|
||||
import type { ColorStripSource } from '../../types.ts';
|
||||
import { bindableValue, bindableColor } from '../../types.ts';
|
||||
import { renderTagChips } from '../../core/tag-input.ts';
|
||||
@@ -46,7 +47,7 @@ function _gradientEntityStripHTML(stops: Array<{ position: number; color: number
|
||||
/* ── Non-picture types set ────────────────────────────────────── */
|
||||
|
||||
const NON_PICTURE_TYPES = new Set([
|
||||
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
|
||||
'static', 'gradient', 'effect', 'composite', 'mapped',
|
||||
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
||||
'math_wave',
|
||||
]);
|
||||
@@ -64,16 +65,6 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
${clockBadge}
|
||||
`;
|
||||
},
|
||||
color_cycle: (source, { clockBadge }) => {
|
||||
const colors = source.colors || [];
|
||||
const swatches = colors.slice(0, 8).map((c: any) =>
|
||||
`<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
|
||||
).join('');
|
||||
return `
|
||||
<span class="stream-card-prop">${swatches}</span>
|
||||
${clockBadge}
|
||||
`;
|
||||
},
|
||||
gradient: (source, { clockBadge, animBadge }) => {
|
||||
const stops = source.stops || [];
|
||||
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
|
||||
@@ -273,6 +264,24 @@ function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Rec
|
||||
|
||||
/* ── Main card builder ────────────────────────────────────────── */
|
||||
|
||||
const STRIP_BADGE: Record<string, string> = {
|
||||
static: 'STRIP · COLOR',
|
||||
gradient: 'STRIP · GRD',
|
||||
effect: 'STRIP · FX',
|
||||
composite: 'STRIP · COMP',
|
||||
mapped: 'STRIP · MAP',
|
||||
audio: 'STRIP · AUDIO',
|
||||
api_input: 'STRIP · API',
|
||||
notification: 'STRIP · NOTIF',
|
||||
daylight: 'STRIP · DAY',
|
||||
candlelight: 'STRIP · CANDLE',
|
||||
weather: 'STRIP · WEATHER',
|
||||
key_colors: 'STRIP · KEY',
|
||||
math_wave: 'STRIP · WAVE',
|
||||
processed: 'STRIP · OUT',
|
||||
picture_advanced: 'STRIP · CALIB',
|
||||
};
|
||||
|
||||
export function createColorStripCard(source: ColorStripSource, pictureSourceMap: Record<string, any>, audioSourceMap: Record<string, any>) {
|
||||
// Clock crosslink badge
|
||||
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
|
||||
@@ -291,45 +300,54 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
|
||||
? renderer(source, { clockBadge, animBadge, audioSourceMap, pictureSourceMap })
|
||||
: _renderPictureCardProps(source, pictureSourceMap);
|
||||
|
||||
const icon = getColorStripIcon(source.source_type);
|
||||
const ledCount = (source as any).led_count || 0;
|
||||
const badgeText = STRIP_BADGE[source.source_type] || 'STRIP · MAPPED';
|
||||
const metaText = ledCount ? `${ledCount} px` : (source.source_type || '').replace(/_/g, ' ');
|
||||
|
||||
const isNotification = source.source_type === 'notification';
|
||||
const isPictureKind = !NON_PICTURE_TYPES.has(source.source_type);
|
||||
const calibrationBtn = isPictureKind
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="${source.source_type === 'picture_advanced' ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
||||
: '';
|
||||
const overlayBtn = isPictureKind
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); toggleCSSOverlay('${source.id}')" title="${t('overlay.toggle')}">${ICON_OVERLAY}</button>`
|
||||
: '';
|
||||
const testNotifyBtn = isNotification
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
|
||||
: '';
|
||||
const notifHistoryBtn = isNotification
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
|
||||
: '';
|
||||
const isKeyColors = source.source_type === 'key_colors';
|
||||
const regionsBtn = isKeyColors
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); configureKCRegions('${source.id}')" title="${t('color_strip.key_colors.configure_regions')}">${ICON_PATTERN_TEMPLATE}</button>`
|
||||
: '';
|
||||
const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
|
||||
|
||||
return wrapCard({
|
||||
dataAttr: 'data-css-id',
|
||||
id: source.id,
|
||||
removeOnclick: `deleteColorStrip('${source.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="card-header">
|
||||
<div class="card-title" title="${escapeHtml(source.name)}">
|
||||
${icon} <span class="card-title-text">${escapeHtml(source.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
${propsHtml}
|
||||
</div>
|
||||
${renderTagChips(source.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||
${calibrationBtn}${overlayBtn}${regionsBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
|
||||
});
|
||||
const iconActions: any[] = [];
|
||||
iconActions.push({ icon: ICON_TEST, onclick: `event.stopPropagation(); testColorStrip('${source.id}')`, title: t('color_strip.test.title') });
|
||||
if (isPictureKind) {
|
||||
const calibrationOnclick = source.source_type === 'picture_advanced'
|
||||
? `showAdvancedCalibration('${source.id}')`
|
||||
: `showCSSCalibration('${source.id}')`;
|
||||
iconActions.push({ icon: ICON_CALIBRATION, onclick: calibrationOnclick, title: t('calibration.title') });
|
||||
iconActions.push({ icon: ICON_OVERLAY, onclick: `event.stopPropagation(); toggleCSSOverlay('${source.id}')`, title: t('overlay.toggle') });
|
||||
}
|
||||
if (isKeyColors) {
|
||||
iconActions.push({ icon: ICON_PATTERN_TEMPLATE, onclick: `event.stopPropagation(); configureKCRegions('${source.id}')`, title: t('color_strip.key_colors.configure_regions') });
|
||||
}
|
||||
if (isNotification) {
|
||||
iconActions.push({ icon: ICON_BELL, onclick: `event.stopPropagation(); testNotification('${source.id}')`, title: t('color_strip.notification.test') });
|
||||
iconActions.push({ icon: ICON_AUTOMATION, onclick: `event.stopPropagation(); showNotificationHistory()`, title: t('color_strip.notification.history.title') });
|
||||
}
|
||||
iconActions.push({ icon: ICON_EDIT, onclick: `showCSSEditor('${source.id}')`, title: t('common.edit') });
|
||||
|
||||
const mod: ModCardOpts = {
|
||||
head: {
|
||||
badge: { text: badgeText },
|
||||
name: source.name,
|
||||
metaHtml: escapeHtml(metaText),
|
||||
leds: ['off'],
|
||||
menu: {
|
||||
duplicateOnclick: `cloneColorStrip('${source.id}')`,
|
||||
hideOnclick: `toggleCardHidden('color-strips','${source.id}')`,
|
||||
deleteOnclick: `deleteColorStrip('${source.id}')`,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
extraHtml: propsHtml ? `<div class="stream-card-props">${propsHtml}</div>` : undefined,
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'STRIP',
|
||||
iconActions,
|
||||
},
|
||||
};
|
||||
const cardHtml = wrapCard({ dataAttr: 'data-css-id', id: source.id, mod });
|
||||
const tagsHtml = renderTagChips(source.tags);
|
||||
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Color Strip Sources — Color Cycle helpers.
|
||||
* Extracted from color-strips.ts to reduce file size.
|
||||
*/
|
||||
|
||||
import { ICON_TRASH } from '../../core/icons.ts';
|
||||
import { rgbArrayToHex, hexToRgbArray } from '../css-gradient-editor.ts';
|
||||
|
||||
/* ── State ────────────────────────────────────────────────────── */
|
||||
|
||||
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
|
||||
let _colorCycleColors = [..._DEFAULT_CYCLE_COLORS];
|
||||
|
||||
/* ── DOM sync ─────────────────────────────────────────────────── */
|
||||
|
||||
function _syncColorCycleFromDom() {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('#color-cycle-colors-list input[type=color]');
|
||||
if (inputs.length > 0) {
|
||||
_colorCycleColors = Array.from(inputs).map(el => el.value);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Rendering ────────────────────────────────────────────────── */
|
||||
|
||||
function _colorCycleRenderList() {
|
||||
const list = document.getElementById('color-cycle-colors-list') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
const canRemove = _colorCycleColors.length > 2;
|
||||
list.innerHTML = _colorCycleColors.map((hex, i) => `
|
||||
<div class="color-cycle-item">
|
||||
<input type="color" value="${hex}">
|
||||
${canRemove
|
||||
? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
|
||||
onclick="colorCycleRemoveColor(${i})">${ICON_TRASH}</button>`
|
||||
: `<div style="height:14px"></div>`}
|
||||
</div>
|
||||
`).join('') + `<div class="color-cycle-item"><button type="button" class="btn btn-secondary color-cycle-add-btn" onclick="colorCycleAddColor()">+</button></div>`;
|
||||
}
|
||||
|
||||
/* ── Public actions ───────────────────────────────────────────── */
|
||||
|
||||
export function colorCycleAddColor() {
|
||||
_syncColorCycleFromDom();
|
||||
_colorCycleColors.push('#ffffff');
|
||||
_colorCycleRenderList();
|
||||
}
|
||||
|
||||
export function colorCycleRemoveColor(i: number) {
|
||||
_syncColorCycleFromDom();
|
||||
if (_colorCycleColors.length <= 2) return;
|
||||
_colorCycleColors.splice(i, 1);
|
||||
_colorCycleRenderList();
|
||||
}
|
||||
|
||||
export function colorCycleGetColors() {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('#color-cycle-colors-list input[type=color]');
|
||||
return Array.from(inputs).map(el => hexToRgbArray(el.value));
|
||||
}
|
||||
|
||||
/* ── Load / Reset ─────────────────────────────────────────────── */
|
||||
|
||||
export function loadColorCycleState(css: any) {
|
||||
const raw = css && css.colors;
|
||||
_colorCycleColors = (raw && raw.length >= 2)
|
||||
? raw.map((c: any) => rgbArrayToHex(c))
|
||||
: [..._DEFAULT_CYCLE_COLORS];
|
||||
_colorCycleRenderList();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Color Strip Sources — CRUD, editor orchestration, type switching.
|
||||
* Sub-modules handle type-specific logic: audio, cards, color-cycle, composite,
|
||||
* Sub-modules handle type-specific logic: audio, cards, composite,
|
||||
* game-event, gradient, mapped, math-wave, notification, test.
|
||||
*/
|
||||
|
||||
@@ -62,9 +62,6 @@ import {
|
||||
mappedSetAvailableSources, mappedRenderList, mappedAddZone, mappedRemoveZone,
|
||||
mappedGetZones, loadMappedState, resetMappedState, getMappedZones,
|
||||
} from './mapped.ts';
|
||||
import {
|
||||
colorCycleAddColor, colorCycleRemoveColor, colorCycleGetColors, loadColorCycleState,
|
||||
} from './color-cycle.ts';
|
||||
import {
|
||||
destroyAudioWidgets, ensureAudioSensitivityWidget, ensureAudioSmoothingWidget,
|
||||
ensureAudioBeatDecayWidget, ensureAudioColorWidget, ensureAudioColorPeakWidget,
|
||||
@@ -90,11 +87,10 @@ export {
|
||||
notificationAddAppOverride, notificationRemoveAppOverride,
|
||||
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
};
|
||||
export { _getAnimationPayload, colorCycleGetColors as _colorCycleGetColors };
|
||||
export { _getAnimationPayload };
|
||||
export { addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange };
|
||||
export { mathWaveAddLayer, mathWaveRemoveLayer };
|
||||
export { mappedAddZone, mappedRemoveZone };
|
||||
export { colorCycleAddColor, colorCycleRemoveColor };
|
||||
export { createColorStripCard };
|
||||
export {
|
||||
onGradientPresetChange, promptAndSaveGradientPreset, deleteAndRefreshGradientPreset,
|
||||
@@ -158,7 +154,6 @@ class CSSEditorModal extends Modal {
|
||||
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
|
||||
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
||||
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
|
||||
cycle_colors: JSON.stringify(colorCycleGetColors()),
|
||||
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
||||
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
||||
effect_color: _effectColorWidget ? JSON.stringify(_effectColorWidget.getValue()) : '[]',
|
||||
@@ -190,6 +185,7 @@ class CSSEditorModal extends Modal {
|
||||
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
|
||||
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
|
||||
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
|
||||
daylight_longitude: (document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value,
|
||||
candlelight_color: _candlelightColorWidget ? JSON.stringify(_candlelightColorWidget.getValue()) : '[]',
|
||||
candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
|
||||
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
|
||||
@@ -307,7 +303,7 @@ async function configureKCRegions(sourceId: string): Promise<void> {
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
const CSS_TYPE_KEYS = [
|
||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
||||
'picture', 'picture_advanced', 'static', 'gradient',
|
||||
'effect', 'composite', 'mapped', 'audio',
|
||||
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
||||
'game_event', 'math_wave',
|
||||
@@ -346,7 +342,6 @@ const CSS_SECTION_MAP: Record<string, string> = {
|
||||
'picture': 'css-editor-picture-section',
|
||||
'picture_advanced': 'css-editor-picture-section',
|
||||
'static': 'css-editor-static-section',
|
||||
'color_cycle': 'css-editor-color-cycle-section',
|
||||
'gradient': 'css-editor-gradient-section',
|
||||
'effect': 'css-editor-effect-section',
|
||||
'composite': 'css-editor-composite-section',
|
||||
@@ -422,7 +417,7 @@ export function onCSSTypeChange() {
|
||||
(document.getElementById('css-editor-led-count-group') as HTMLElement).style.display =
|
||||
hasLedCount.includes(type) ? '' : 'none';
|
||||
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
|
||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||
|
||||
@@ -1006,15 +1001,6 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
return { name, color: _ensureStaticColorWidget().getValue(), animation: _getAnimationPayload() };
|
||||
},
|
||||
},
|
||||
color_cycle: {
|
||||
load(css) { loadColorCycleState(css); },
|
||||
reset() { loadColorCycleState(null); },
|
||||
getPayload(name) {
|
||||
const cycleColors = colorCycleGetColors();
|
||||
if (cycleColors.length < 2) { cssEditorModal.showError(t('color_strip.color_cycle.min_colors')); return null; }
|
||||
return { name, colors: cycleColors };
|
||||
},
|
||||
},
|
||||
gradient: {
|
||||
load(css) {
|
||||
const gradientId = css.gradient_id || '';
|
||||
@@ -1180,6 +1166,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
(document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = '50';
|
||||
(document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = 0.0 as any;
|
||||
(document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = '0';
|
||||
_syncDaylightSpeedVisibility();
|
||||
},
|
||||
getPayload(name) {
|
||||
return {
|
||||
@@ -1563,7 +1550,7 @@ export async function saveCSSEditor() {
|
||||
|
||||
payload.source_type = knownType ? sourceType : 'picture';
|
||||
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||
if (clockTypes.includes(sourceType)) {
|
||||
payload.clock_id = (document.getElementById('css-editor-clock') as HTMLInputElement).value || null;
|
||||
}
|
||||
|
||||
@@ -15,14 +15,14 @@ import {
|
||||
} from '../../core/icons.ts';
|
||||
import { EntitySelect } from '../../core/entity-palette.ts';
|
||||
import { hexToRgbArray, getGradientStops } from '../css-gradient-editor.ts';
|
||||
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './index.ts';
|
||||
import { testNotification, _getAnimationPayload } from './index.ts';
|
||||
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './notification.ts';
|
||||
import { openAuthedWs } from '../../core/ws-auth.ts';
|
||||
|
||||
/* ── Preview config builder ───────────────────────────────────── */
|
||||
|
||||
const _PREVIEW_TYPES = new Set([
|
||||
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'notification',
|
||||
'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification',
|
||||
]);
|
||||
|
||||
function _collectPreviewConfig() {
|
||||
@@ -35,10 +35,6 @@ function _collectPreviewConfig() {
|
||||
const stops = getGradientStops();
|
||||
if (stops.length < 2) return null;
|
||||
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload(), easing: (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value };
|
||||
} else if (sourceType === 'color_cycle') {
|
||||
const colors = _colorCycleGetColors();
|
||||
if (colors.length < 2) return null;
|
||||
config = { source_type: 'color_cycle', colors };
|
||||
} else if (sourceType === 'effect') {
|
||||
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
|
||||
if (['meteor', 'comet', 'bouncing_ball'].includes(config.effect_type)) { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
|
||||
|
||||
@@ -75,11 +75,17 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
|
||||
const PERF_CELL_LABEL_KEYS: Record<string, string> = {
|
||||
patches: 'dashboard.perf.active_patches',
|
||||
fps: 'dashboard.perf.total_fps',
|
||||
capture_fps: 'dashboard.perf.total_capture_fps',
|
||||
capture_fps_actual: 'dashboard.perf.total_capture_fps_actual',
|
||||
errors: 'dashboard.perf.errors',
|
||||
devices: 'dashboard.perf.devices',
|
||||
cpu: 'dashboard.perf.cpu',
|
||||
ram: 'dashboard.perf.ram',
|
||||
gpu: 'dashboard.perf.gpu',
|
||||
temp: 'dashboard.perf.temp',
|
||||
network: 'dashboard.perf.network',
|
||||
device_latency: 'dashboard.perf.device_latency',
|
||||
send_timing: 'dashboard.perf.send_timing',
|
||||
};
|
||||
|
||||
let _unsubscribe: (() => void) | null = null;
|
||||
@@ -129,6 +135,13 @@ function _mountPanel(): void {
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
// Force a layout flush so the initial off-screen transform commits before
|
||||
// the caller adds `.is-open`. Without this, on the first open after a page
|
||||
// reload the browser collapses both styles into one paint and the slide-in
|
||||
// transition is skipped.
|
||||
void panel.offsetWidth;
|
||||
void backdrop.offsetWidth;
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && panel.classList.contains('is-open')) {
|
||||
closeDashboardCustomize();
|
||||
|
||||
@@ -35,13 +35,18 @@ export type SectionKey =
|
||||
export type PerfCellKey =
|
||||
| 'patches'
|
||||
| 'fps'
|
||||
| 'capture_fps'
|
||||
| 'capture_fps_actual'
|
||||
| 'errors'
|
||||
| 'devices'
|
||||
| 'cpu'
|
||||
| 'ram'
|
||||
| 'gpu'
|
||||
| 'temp'
|
||||
// Reserved.
|
||||
| 'network'
|
||||
| 'device_latency'
|
||||
| 'send_timing'
|
||||
// Reserved.
|
||||
| 'disk'
|
||||
| 'audio-peak';
|
||||
|
||||
@@ -142,11 +147,17 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
|
||||
perfCells: [
|
||||
_defaultPerfCell('patches'),
|
||||
_defaultPerfCell('fps'),
|
||||
_defaultPerfCell('capture_fps'),
|
||||
_defaultPerfCell('capture_fps_actual', false),
|
||||
_defaultPerfCell('errors'),
|
||||
_defaultPerfCell('devices'),
|
||||
_defaultPerfCell('cpu'),
|
||||
_defaultPerfCell('ram'),
|
||||
_defaultPerfCell('gpu'),
|
||||
_defaultPerfCell('temp', false),
|
||||
_defaultPerfCell('network', false),
|
||||
_defaultPerfCell('device_latency', false),
|
||||
_defaultPerfCell('send_timing', false),
|
||||
],
|
||||
global: {
|
||||
width: 'full',
|
||||
@@ -218,6 +229,114 @@ function _clone(layout: DashboardLayoutV1, presetActive?: string): DashboardLayo
|
||||
};
|
||||
}
|
||||
|
||||
/** Structural deep-equal that ignores `undefined` properties and key order.
|
||||
* Needed because a saved layout coming back from JSON.parse may omit
|
||||
* optional fields (e.g. `colorOverride`) that the factory output also
|
||||
* omits — but a naive `JSON.stringify` comparison can still differ if
|
||||
* insertion order ever drifts. */
|
||||
function _deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!_deepEqual(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const ao = a as Record<string, unknown>;
|
||||
const bo = b as Record<string, unknown>;
|
||||
const aKeys = Object.keys(ao).filter(k => ao[k] !== undefined);
|
||||
const bKeys = Object.keys(bo).filter(k => bo[k] !== undefined);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (const k of aKeys) {
|
||||
if (!_deepEqual(ao[k], bo[k])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Detect which preset (if any) the layout currently matches. Recomputed
|
||||
* on every save/load so the "modified" hint is the truth, not a flag that
|
||||
* drifts when a user edits then reverts a setting.
|
||||
*
|
||||
* Compares only the data-bearing fields — `presetActive` itself is ignored,
|
||||
* since it's the value we're computing. */
|
||||
function _computeActivePreset(layout: DashboardLayoutV1): string | undefined {
|
||||
for (const [name, factory] of Object.entries(PRESETS)) {
|
||||
const p = factory();
|
||||
if (
|
||||
layout.version === p.version &&
|
||||
_deepEqual(layout.global, p.global) &&
|
||||
_deepEqual(layout.sections, p.sections) &&
|
||||
_deepEqual(layout.perfCells, p.perfCells)
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Diagnostic: list per-preset mismatches for the current layout. Exposed
|
||||
* on `window.__dashboardLayoutDiff` so a user reporting "MODIFIED with no
|
||||
* changes" can run it from DevTools and see exactly which field drifted. */
|
||||
function _diffAgainstPresets(layout: DashboardLayoutV1): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const [name, factory] of Object.entries(PRESETS)) {
|
||||
const p = factory();
|
||||
const diffs: string[] = [];
|
||||
if (layout.version !== p.version) diffs.push(`version: ${layout.version} vs ${p.version}`);
|
||||
const asRec = (o: object): Record<string, unknown> => o as unknown as Record<string, unknown>;
|
||||
if (!_deepEqual(layout.global, p.global)) {
|
||||
for (const k of Object.keys({ ...layout.global, ...p.global })) {
|
||||
const lv = asRec(layout.global)[k];
|
||||
const pv = asRec(p.global)[k];
|
||||
if (!_deepEqual(lv, pv)) diffs.push(`global.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`);
|
||||
}
|
||||
}
|
||||
if (!_deepEqual(layout.sections, p.sections)) {
|
||||
const lKeys = layout.sections.map(s => s.key).join(',');
|
||||
const pKeys = p.sections.map(s => s.key).join(',');
|
||||
if (lKeys !== pKeys) diffs.push(`sections.order: [${lKeys}] vs [${pKeys}]`);
|
||||
for (const ls of layout.sections) {
|
||||
const ps = p.sections.find(x => x.key === ls.key);
|
||||
if (!ps) { diffs.push(`sections.${ls.key}: extra`); continue; }
|
||||
if (!_deepEqual(ls, ps)) {
|
||||
for (const k of Object.keys({ ...ls, ...ps })) {
|
||||
const lv = asRec(ls)[k];
|
||||
const pv = asRec(ps)[k];
|
||||
if (!_deepEqual(lv, pv)) diffs.push(`sections.${ls.key}.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!_deepEqual(layout.perfCells, p.perfCells)) {
|
||||
const lKeys = layout.perfCells.map(c => c.key).join(',');
|
||||
const pKeys = p.perfCells.map(c => c.key).join(',');
|
||||
if (lKeys !== pKeys) diffs.push(`perfCells.order: [${lKeys}] vs [${pKeys}]`);
|
||||
for (const lc of layout.perfCells) {
|
||||
const pc = p.perfCells.find(x => x.key === lc.key);
|
||||
if (!pc) { diffs.push(`perfCells.${lc.key}: extra`); continue; }
|
||||
if (!_deepEqual(lc, pc)) {
|
||||
for (const k of Object.keys({ ...lc, ...pc })) {
|
||||
const lv = asRec(lc)[k];
|
||||
const pv = asRec(pc)[k];
|
||||
if (!_deepEqual(lv, pv)) diffs.push(`perfCells.${lc.key}.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out[name] = diffs;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as unknown as Record<string, unknown>).__dashboardLayoutDiff = () => _diffAgainstPresets(_current);
|
||||
}
|
||||
|
||||
let _current: DashboardLayoutV1 = _clone(DEFAULT_LAYOUT, 'studio');
|
||||
let _serverSyncedOnce = false;
|
||||
const _listeners = new Set<() => void>();
|
||||
@@ -289,7 +408,7 @@ export async function syncDashboardLayoutFromServer(): Promise<void> {
|
||||
/** Persist a layout. Updates in-memory state immediately, debounces
|
||||
* the network write, and notifies listeners synchronously. */
|
||||
export function saveDashboardLayout(next: DashboardLayoutV1): void {
|
||||
_current = _clone(next, next.presetActive);
|
||||
_current = _clone(next, _computeActivePreset(next));
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ }
|
||||
_notify();
|
||||
if (_saveTimer) clearTimeout(_saveTimer);
|
||||
@@ -540,7 +659,7 @@ function _mergeWithDefaults(input: unknown): DashboardLayoutV1 {
|
||||
base.global = { ...base.global, ...obj.global };
|
||||
}
|
||||
|
||||
if (typeof obj.presetActive === 'string') base.presetActive = obj.presetActive;
|
||||
base.presetActive = _computeActivePreset(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -575,5 +694,6 @@ function _migrateFromLegacyKeys(): DashboardLayoutV1 {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
layout.presetActive = _computeActivePreset(layout);
|
||||
return layout;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval,
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
|
||||
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateDevices, rerenderPerfGrid } from './perf-charts.ts';
|
||||
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalCaptureFpsActual, updateTotalErrors, updateDevices, updateNetworkThroughput, updateDeviceLatency, updateSendTiming, rerenderPerfGrid } from './perf-charts.ts';
|
||||
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
|
||||
import { isActiveTab } from '../core/tab-registry.ts';
|
||||
import {
|
||||
@@ -39,6 +39,11 @@ let _fpsCurrentHistory: Record<string, number[]> = {};
|
||||
let _fpsCharts: Record<string, any> = {};
|
||||
let _lastRunningIds: string[] = [];
|
||||
let _lastSyncClockIds: string = '';
|
||||
/** Previous cumulative `bytes_sent` summed across running targets.
|
||||
* Used to convert the WLED transport byte counter into a per-poll
|
||||
* delta that drives the Network throughput sparkline. `null` until
|
||||
* the first poll so we don't emit a phantom spike on page load. */
|
||||
let _prevTotalBytesSent: number | null = null;
|
||||
let _uptimeBase: Record<string, UptimeBase> = {};
|
||||
let _uptimeTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let _uptimeElements: Record<string, Element> = {};
|
||||
@@ -99,10 +104,6 @@ function _startUptimeTimer(): void {
|
||||
if (!el) continue;
|
||||
const seconds = _getInterpolatedUptime(id);
|
||||
if (seconds != null) {
|
||||
// Pure text — the .mod-metric "UPTIME" label already
|
||||
// carries the icon meaning, and dropping it gives the
|
||||
// value enough room for "4m 32s" / "1h 17m" without
|
||||
// clipping inside the fixed-width metric cell.
|
||||
el.textContent = formatUptime(seconds);
|
||||
}
|
||||
}
|
||||
@@ -322,9 +323,11 @@ function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string {
|
||||
? `${t('ha_source.connected')} — ${conn.entity_count} ${t('dashboard.integrations.entities')}`
|
||||
: t('ha_source.disconnected');
|
||||
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
|
||||
const host = (conn.host || '').trim();
|
||||
const entitiesPart = `${conn.entity_count} ${t('dashboard.integrations.entities')}`;
|
||||
const subtitle = conn.connected
|
||||
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
|
||||
: t('ha_source.disconnected');
|
||||
? (host ? `${host} · ${entitiesPart}` : entitiesPart)
|
||||
: (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
|
||||
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'HA';
|
||||
const ledCls = conn.connected ? 'led on blink' : 'led';
|
||||
const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE';
|
||||
@@ -402,9 +405,11 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
|
||||
}
|
||||
const meta = card.querySelector('.mod-meta');
|
||||
if (meta) {
|
||||
const host = (conn.host || '').trim();
|
||||
const entitiesPart = `${conn.entity_count} ${t('dashboard.integrations.entities')}`;
|
||||
meta.textContent = conn.connected
|
||||
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
|
||||
: t('ha_source.disconnected');
|
||||
? (host ? `${host} · ${entitiesPart}` : entitiesPart)
|
||||
: (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
|
||||
}
|
||||
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
|
||||
}
|
||||
@@ -601,6 +606,32 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
const statesObj = payload.states || {};
|
||||
const deviceStateList = Object.values(statesObj) as any[];
|
||||
updateDevices(deviceStateList);
|
||||
|
||||
// Device-latency cell — avg / max ping latency across
|
||||
// online devices. Already deduplicated by device_id
|
||||
// (this list is keyed on device, not target). Offline
|
||||
// devices contribute to the total count but not the
|
||||
// latency aggregate.
|
||||
let onlineCount = 0;
|
||||
let latencyMax = 0;
|
||||
let latencySum = 0;
|
||||
let latencyN = 0;
|
||||
for (const ds of deviceStateList) {
|
||||
if (ds?.device_online) onlineCount++;
|
||||
const l = ds?.device_latency_ms;
|
||||
if (typeof l === 'number' && Number.isFinite(l) && l >= 0) {
|
||||
latencySum += l;
|
||||
latencyN += 1;
|
||||
if (l > latencyMax) latencyMax = l;
|
||||
}
|
||||
}
|
||||
const latencyAvg = latencyN > 0 ? latencySum / latencyN : null;
|
||||
updateDeviceLatency(
|
||||
latencyAvg,
|
||||
latencyN > 0 ? latencyMax : null,
|
||||
onlineCount,
|
||||
deviceStateList.length,
|
||||
);
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
@@ -652,8 +683,17 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
// Total FPS cell in the perf strip. `fpsTargetSum` is drawn as
|
||||
// a dashed reference line ("max achievable throughput").
|
||||
const fpsValues: number[] = [];
|
||||
const captureFpsValues: number[] = [];
|
||||
let fpsSum = 0;
|
||||
let fpsTargetSum = 0;
|
||||
let captureFpsSum = 0;
|
||||
// Capture-actual aggregates: only count targets whose stream
|
||||
// reports a measured rate (capture-backed, e.g. screen capture).
|
||||
// Synthetic streams report null and are excluded so the cell
|
||||
// can read "no captures" instead of "0 fps".
|
||||
let captureFpsActualSum = 0;
|
||||
let captureActualReportingCount = 0;
|
||||
let captureFpsActualTargetSum = 0;
|
||||
for (const r of running) {
|
||||
const fps = r.state?.fps_actual != null ? r.state.fps_actual
|
||||
: r.state?.fps_current != null ? r.state.fps_current
|
||||
@@ -666,10 +706,75 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
?? (r.settings || {}).fps
|
||||
?? r.update_rate;
|
||||
if (typeof tgt === 'number' && tgt > 0) fpsTargetSum += tgt;
|
||||
const captureFps = r.state?.fps_capture;
|
||||
if (typeof captureFps === 'number' && captureFps > 0) {
|
||||
captureFpsValues.push(captureFps);
|
||||
captureFpsSum += captureFps;
|
||||
}
|
||||
const captureFpsActual = r.state?.fps_capture_actual;
|
||||
if (typeof captureFpsActual === 'number') {
|
||||
captureFpsActualSum += captureFpsActual;
|
||||
captureActualReportingCount += 1;
|
||||
// Use this target's source-side rate as the per-capture
|
||||
// ceiling so the "% of requested" subtitle matches the
|
||||
// captures actually being measured.
|
||||
if (typeof captureFps === 'number' && captureFps > 0) {
|
||||
captureFpsActualTargetSum += captureFps;
|
||||
}
|
||||
}
|
||||
}
|
||||
const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null;
|
||||
const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
|
||||
updateTotalFps(fpsSum, fpsMin, fpsMax, fpsTargetSum);
|
||||
const captureFpsMin = captureFpsValues.length > 0 ? Math.min(...captureFpsValues) : null;
|
||||
const captureFpsMax = captureFpsValues.length > 0 ? Math.max(...captureFpsValues) : null;
|
||||
updateTotalCaptureFps(captureFpsSum, captureFpsMin, captureFpsMax);
|
||||
updateTotalCaptureFpsActual(captureFpsActualSum, captureFpsActualTargetSum, captureActualReportingCount);
|
||||
|
||||
// Errors / dropped frames — fed cumulative totals; the perf
|
||||
// cell turns them into per-second rates by tracking deltas
|
||||
// across polls. Computed across running targets only so a
|
||||
// long-stopped target's historical errors don't keep the
|
||||
// counter elevated forever.
|
||||
let totalErrors = 0;
|
||||
let totalSkipped = 0;
|
||||
let totalBytesSent = 0;
|
||||
let sendTimingSum = 0;
|
||||
let sendTimingMax = 0;
|
||||
let sendTimingCount = 0;
|
||||
for (const r of running) {
|
||||
const e = r.metrics?.errors_count;
|
||||
if (typeof e === 'number' && e > 0) totalErrors += e;
|
||||
const s = r.state?.frames_skipped;
|
||||
if (typeof s === 'number' && s > 0) totalSkipped += s;
|
||||
const b = r.state?.bytes_sent;
|
||||
if (typeof b === 'number' && b > 0) totalBytesSent += b;
|
||||
const t = r.state?.timing_send_ms;
|
||||
if (typeof t === 'number' && Number.isFinite(t) && t >= 0) {
|
||||
sendTimingSum += t;
|
||||
sendTimingCount += 1;
|
||||
if (t > sendTimingMax) sendTimingMax = t;
|
||||
}
|
||||
}
|
||||
updateTotalErrors(totalErrors, totalSkipped, dashboardPollInterval);
|
||||
|
||||
// Network throughput — convert cumulative byte counter into
|
||||
// a per-second rate via deltas, same shape as the errors
|
||||
// cell. Counter resets (target stop/restart) leave the
|
||||
// total unchanged or smaller; rate is clamped non-negative
|
||||
// inside the perf-charts module.
|
||||
const pollSec = Math.max(0.05, dashboardPollInterval / 1000);
|
||||
const bytesDelta = _prevTotalBytesSent != null
|
||||
? Math.max(0, totalBytesSent - _prevTotalBytesSent)
|
||||
: 0;
|
||||
const bytesPerSec = _prevTotalBytesSent != null ? bytesDelta / pollSec : 0;
|
||||
_prevTotalBytesSent = totalBytesSent;
|
||||
updateNetworkThroughput(bytesPerSec, totalBytesSent);
|
||||
|
||||
// Send-timing — already an instantaneous "ms last frame"
|
||||
// value, so we just average / max across running targets.
|
||||
const sendTimingAvg = sendTimingCount > 0 ? sendTimingSum / sendTimingCount : 0;
|
||||
updateSendTiming(sendTimingAvg, sendTimingMax, sendTimingCount);
|
||||
|
||||
// Check if we can do an in-place metrics update (same targets, not first load)
|
||||
const newRunningIds = running.map(t => t.id).sort().join(',');
|
||||
|
||||
@@ -12,8 +12,9 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.ts';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts } from '../core/mod-card.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { getBaseOrigin } from './settings.ts';
|
||||
@@ -22,6 +23,30 @@ import type { Device } from '../types.ts';
|
||||
let _deviceTagsInput: any = null;
|
||||
let _settingsCsptEntitySelect: any = null;
|
||||
|
||||
/* The General Settings modal groups its many conditional fields into
|
||||
four `.ds-section` panels (Identity / Connection / Hardware / Behavior).
|
||||
showSettings() toggles individual `.form-group` visibility by device
|
||||
type and capability — this helper then collapses any section whose
|
||||
form-groups have all ended up `display: none`, so the user never
|
||||
sees a section header with nothing underneath it. */
|
||||
function _updateSettingsSectionVisibility() {
|
||||
const root = document.getElementById('device-settings-modal');
|
||||
if (!root) return;
|
||||
const sections = root.querySelectorAll<HTMLElement>('.ds-section');
|
||||
sections.forEach((sec) => {
|
||||
if (sec.dataset.dsKey === 'identity') {
|
||||
sec.dataset.dsEmpty = 'false';
|
||||
return;
|
||||
}
|
||||
const groups = sec.querySelectorAll<HTMLElement>('.form-group');
|
||||
let anyVisible = false;
|
||||
groups.forEach((g) => {
|
||||
if (g.style.display !== 'none') anyVisible = true;
|
||||
});
|
||||
sec.dataset.dsEmpty = anyVisible ? 'false' : 'true';
|
||||
});
|
||||
}
|
||||
|
||||
function _ensureSettingsCsptSelect() {
|
||||
const sel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
@@ -127,76 +152,180 @@ export function createDeviceCard(device: Device & { state?: any }) {
|
||||
const devVersion = state.device_version;
|
||||
const devLastChecked = state.device_last_checked;
|
||||
|
||||
let healthClass, healthTitle, healthLabel;
|
||||
// Health dot — kept inline beside the name so the existing
|
||||
// event-driven status updates (.health-dot[data-device-id=…])
|
||||
// continue to work without needing new selectors.
|
||||
let healthClass: string, healthTitle: string;
|
||||
if (devLastChecked === null || devLastChecked === undefined) {
|
||||
healthClass = 'health-unknown';
|
||||
healthTitle = t('device.health.checking');
|
||||
healthLabel = '';
|
||||
} else if (devOnline) {
|
||||
healthClass = 'health-online';
|
||||
healthTitle = `${t('device.health.online')}`;
|
||||
healthTitle = t('device.health.online');
|
||||
if (devName) healthTitle += ` - ${devName}`;
|
||||
if (devVersion) healthTitle += ` v${devVersion}`;
|
||||
if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
|
||||
healthLabel = '';
|
||||
} else {
|
||||
healthClass = 'health-offline';
|
||||
healthTitle = t('device.health.offline');
|
||||
if (state.device_error) healthTitle += `: ${state.device_error}`;
|
||||
healthLabel = '';
|
||||
}
|
||||
const healthDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status" aria-label="${escapeHtml(healthTitle)}"></span>`;
|
||||
|
||||
// ── LED bezel — 1-3 dots reflecting connection state ──────────
|
||||
let leds: LedState[];
|
||||
if (devLastChecked === null || devLastChecked === undefined) {
|
||||
leds = ['off'];
|
||||
} else if (devOnline) {
|
||||
leds = ['on', 'blink', 'blink'];
|
||||
} else {
|
||||
leds = ['fault'];
|
||||
}
|
||||
|
||||
const ledCount = state.device_led_count || device.led_count;
|
||||
// ── Type label for the badge ──
|
||||
const badgeText = `${(device.device_type || 'wled').toUpperCase()} · OUT`;
|
||||
|
||||
// Parse zone names from OpenRGB URL for badge display
|
||||
// ── Metadata sub-line — URL becomes a clickable anchor when http(s),
|
||||
// plain text otherwise. Built as HTML so the link is real, not a
|
||||
// chip. Each part is HTML-escaped individually. ──
|
||||
const metaPartsHtml: string[] = [];
|
||||
if (device.url && device.url.startsWith('http')) {
|
||||
const safeUrl = escapeHtml(device.url);
|
||||
const display = escapeHtml(device.url.replace(/^https?:\/\//, ''));
|
||||
metaPartsHtml.push(
|
||||
`<a class="mod-meta__link" href="${safeUrl}" target="_blank" rel="noopener" title="${t('device.button.webui') || 'Open device web UI'}" onclick="event.stopPropagation();">${display} ${ICON_WEB}</a>`
|
||||
);
|
||||
} else if (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://')) {
|
||||
metaPartsHtml.push(escapeHtml(device.url));
|
||||
}
|
||||
if (devVersion) metaPartsHtml.push(escapeHtml(`v${devVersion}`));
|
||||
|
||||
// ── Metric strip — three blocks: LED count, latency, and chip type
|
||||
// (which carries both the LED chip identifier and RGB/RGBW colour
|
||||
// channel layout, stacked in two rows). ──
|
||||
const ledCount = state.device_led_count || device.led_count;
|
||||
const metrics: ModMetricOpts[] = [];
|
||||
if (ledCount) {
|
||||
metrics.push({ k: t('device.led_count') || 'PIXELS', v: String(ledCount), title: t('device.led_count') });
|
||||
}
|
||||
if (devOnline && devLatency !== null && devLatency !== undefined) {
|
||||
metrics.push({ k: 'LAT', v: `${Math.round(devLatency)}<small>ms</small>`, accent: true });
|
||||
}
|
||||
const ledTypeName = state.device_led_type
|
||||
? state.device_led_type.replace(/ RGBW$/, '')
|
||||
: null;
|
||||
const colorChannels = state.device_rgbw === true ? 'RGBW'
|
||||
: state.device_rgbw === false ? 'RGB'
|
||||
: null;
|
||||
if (ledTypeName || colorChannels) {
|
||||
const primary = ledTypeName ?? colorChannels!;
|
||||
const secondary = ledTypeName && colorChannels ? colorChannels : null;
|
||||
const subHtml = secondary
|
||||
? `<span class="v-sub">${escapeHtml(secondary)}</span>`
|
||||
: '';
|
||||
metrics.push({
|
||||
k: t('dashboard.device.chip') || 'CHIP',
|
||||
v: `${escapeHtml(primary)}${subHtml}`,
|
||||
variant: 'text-stack',
|
||||
title: [state.device_led_type, colorChannels].filter(Boolean).join(' · ') || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Chips: OpenRGB zones (LED type/RGBW now live in the metric strip) ──
|
||||
const openrgbZones = isOpenrgbDevice(device.device_type)
|
||||
? _splitOpenrgbZone(device.url).zones : [];
|
||||
const chips: ModChipOpts[] = [];
|
||||
if (openrgbZones.length) {
|
||||
for (const z of openrgbZones) {
|
||||
chips.push({ icon: ICON_LED, text: String(z), title: t('device.openrgb.zone') });
|
||||
}
|
||||
}
|
||||
|
||||
return wrapCard({
|
||||
// ── Patch indicator state ──
|
||||
const isOnline = devOnline && devLastChecked != null;
|
||||
const patchState: 'live' | 'standby' | 'offline' | 'idle' =
|
||||
devLastChecked == null ? 'idle' :
|
||||
isOnline ? 'live' :
|
||||
'offline';
|
||||
const patchLabel = devLastChecked == null ? (t('device.health.checking') || 'CHECKING').toUpperCase()
|
||||
: isOnline ? (t('device.health.online') || 'ONLINE').toUpperCase()
|
||||
: (t('device.health.offline') || 'OFFLINE').toUpperCase();
|
||||
|
||||
// ── Brightness fader ──
|
||||
const hasBrightness = (device.capabilities || []).includes('brightness_control');
|
||||
const brightnessVal = _deviceBrightnessCache[device.id] ?? 0;
|
||||
const brightnessLoaded = _deviceBrightnessCache[device.id] != null;
|
||||
|
||||
// ── Foot actions ──
|
||||
const hasPower = (device.capabilities || []).includes('power_control');
|
||||
const iconActions: ModBtnOpts[] = [];
|
||||
if (hasPower && isOnline) {
|
||||
// Power-off as a stop-style icon button (was in topButtons before)
|
||||
iconActions.push({
|
||||
icon: ICON_STOP_PLAIN,
|
||||
onclick: `turnOffDevice('${device.id}')`,
|
||||
title: t('device.button.power_off'),
|
||||
variant: 'stop',
|
||||
});
|
||||
}
|
||||
iconActions.push({
|
||||
icon: ICON_REFRESH,
|
||||
onclick: `event.stopPropagation(); pingDevice('${device.id}')`,
|
||||
title: t('device.button.ping'),
|
||||
});
|
||||
iconActions.push({
|
||||
icon: ICON_SETTINGS,
|
||||
onclick: `showSettings('${device.id}')`,
|
||||
title: t('device.button.settings'),
|
||||
});
|
||||
|
||||
const mod: ModCardOpts = {
|
||||
head: {
|
||||
badge: { text: badgeText },
|
||||
name: device.name || device.id,
|
||||
metaHtml: metaPartsHtml.length ? metaPartsHtml.join(' · ') : undefined,
|
||||
healthDot,
|
||||
leds,
|
||||
menu: {
|
||||
duplicateOnclick: `cloneDevice('${device.id}')`,
|
||||
hideOnclick: `toggleCardHidden('led-devices','${device.id}')`,
|
||||
deleteOnclick: `removeDevice('${device.id}')`,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
metrics: metrics.length ? metrics : undefined,
|
||||
chips: chips.length ? chips : undefined,
|
||||
fader: hasBrightness ? {
|
||||
label: t('device.brightness') || 'Bright',
|
||||
value: brightnessVal,
|
||||
max: 255,
|
||||
sliderId: undefined,
|
||||
oninput: `updateBrightnessLabel('${device.id}', this.value)`,
|
||||
onchange: `saveCardBrightness('${device.id}', this.value)`,
|
||||
dataAttrs: { 'data-device-brightness': device.id },
|
||||
disabled: !brightnessLoaded,
|
||||
} : undefined,
|
||||
},
|
||||
foot: {
|
||||
patchState,
|
||||
patchLabel,
|
||||
iconActions,
|
||||
},
|
||||
running: isOnline && hasBrightness && brightnessVal > 0,
|
||||
};
|
||||
|
||||
// Tag chips render via existing renderTagChips() — append after wrapCard
|
||||
// returns, since the modular API doesn't yet have a "tags" slot.
|
||||
const cardHtml = wrapCard({
|
||||
dataAttr: 'data-device-id',
|
||||
id: device.id,
|
||||
topButtons: (device.capabilities || []).includes('power_control') ? `<button class="card-top-btn card-power-btn" onclick="turnOffDevice('${device.id}')" title="${t('device.button.power_off')}">${ICON_STOP_PLAIN}</button>` : '',
|
||||
removeOnclick: `removeDevice('${device.id}')`,
|
||||
removeTitle: t('device.button.remove'),
|
||||
content: `
|
||||
<div class="card-header">
|
||||
<div class="card-title" title="${escapeHtml(device.name || device.id)}">
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
|
||||
<span class="card-title-text">${device.name || device.id}</span>
|
||||
${healthLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-subtitle">
|
||||
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
|
||||
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
||||
${openrgbZones.length
|
||||
? openrgbZones.map((z: any) => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
|
||||
: (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')}
|
||||
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||
</div>
|
||||
<div class="stream-card-props"><span class="stream-card-prop" style="opacity:0.65;" data-last-seen="${device.id}"></span></div>
|
||||
${(device.capabilities || []).includes('brightness_control') ? `
|
||||
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
||||
<input type="range" class="brightness-slider" min="0" max="255"
|
||||
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
|
||||
oninput="updateBrightnessLabel('${device.id}', this.value)"
|
||||
onchange="saveCardBrightness('${device.id}', this.value)"
|
||||
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
|
||||
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
|
||||
</div>` : ''}
|
||||
${renderTagChips(device.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary card-ping-btn" onclick="event.stopPropagation(); pingDevice('${device.id}')" title="${t('device.button.ping')}">
|
||||
${ICON_REFRESH}
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneDevice('${device.id}')" title="${t('common.clone')}">
|
||||
${ICON_CLONE}
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||
${ICON_SETTINGS}
|
||||
</button>`,
|
||||
classes: hasBrightness && !brightnessLoaded ? 'brightness-loading' : '',
|
||||
mod,
|
||||
});
|
||||
const tags = renderTagChips(device.tags);
|
||||
if (!tags) return cardHtml;
|
||||
// Insert tags before the closing </div> of the wrapper card.
|
||||
return cardHtml.replace(/<\/div>\s*$/, `${tags}</div>`);
|
||||
}
|
||||
|
||||
export async function turnOffDevice(deviceId: any) {
|
||||
@@ -519,6 +648,7 @@ export async function showSettings(deviceId: any) {
|
||||
const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
|
||||
if (csptSel) csptSel.value = device.default_css_processing_template_id || '';
|
||||
|
||||
_updateSettingsSectionVisibility();
|
||||
settingsModal.snapshot();
|
||||
settingsModal.open();
|
||||
|
||||
@@ -614,7 +744,17 @@ export async function saveDeviceSettings() {
|
||||
// Brightness
|
||||
export function updateBrightnessLabel(deviceId: any, value: any) {
|
||||
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
|
||||
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
|
||||
if (!slider) return;
|
||||
const v = parseInt(value);
|
||||
slider.title = Math.round(v / 255 * 100) + '%';
|
||||
// mod-card fader visuals — fill width + numeric readout
|
||||
const fader = slider.closest('.mod-fader');
|
||||
if (fader) {
|
||||
const fill = fader.querySelector('.mod-fader__fill') as HTMLElement | null;
|
||||
if (fill) fill.style.width = `${(v / 255) * 100}%`;
|
||||
const valEl = fader.querySelector('.mod-fader__v') as HTMLElement | null;
|
||||
if (valEl) valEl.textContent = String(v);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCardBrightness(deviceId: any, value: any) {
|
||||
@@ -649,11 +789,17 @@ export async function fetchDeviceBrightness(deviceId: any) {
|
||||
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
|
||||
if (slider) {
|
||||
slider.value = data.brightness;
|
||||
slider.title = Math.round(data.brightness / 255 * 100) + '%';
|
||||
slider.disabled = false;
|
||||
// Sync the mod-fader's fill + numeric readout via the
|
||||
// shared visual-update helper.
|
||||
updateBrightnessLabel(deviceId, data.brightness);
|
||||
}
|
||||
// Legacy wrapper (pre-migration cards)
|
||||
const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
|
||||
if (wrap) wrap.classList.remove('brightness-loading');
|
||||
// mod-card variant — loading flag lives on the card itself
|
||||
const card = slider?.closest('.card.mod-card') as HTMLElement | null;
|
||||
if (card) card.classList.remove('brightness-loading');
|
||||
} catch (err) {
|
||||
// Silently fail — device may be offline
|
||||
} finally {
|
||||
|
||||
@@ -25,33 +25,61 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
|
||||
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
|
||||
_pickerEngineType = engineType || null;
|
||||
const lightbox = document.getElementById('display-picker-lightbox')!;
|
||||
const canvas = document.getElementById('display-picker-canvas')!;
|
||||
|
||||
// Use "Select a Device" title for engines with own display lists (camera, scrcpy, etc.)
|
||||
const titleEl = lightbox.querySelector('.display-picker-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = t(_pickerEngineType ? 'displays.picker.title.device' : 'displays.picker.title');
|
||||
// Device-picker variants (camera, scrcpy) drop the big "Select Display"
|
||||
// title — the eyebrow channel strip already reads "Device · List", so
|
||||
// a redundant title rack just steals vertical space. The title only
|
||||
// renders in monitor-pick mode.
|
||||
const isDevicePicker = !!_pickerEngineType;
|
||||
const content = lightbox.querySelector('.display-picker-content');
|
||||
content?.classList.toggle('display-picker-content--no-title', isDevicePicker);
|
||||
|
||||
const setI18n = (selector: string, key: string) => {
|
||||
const el = lightbox.querySelector(selector);
|
||||
if (!el) return;
|
||||
el.setAttribute('data-i18n', key);
|
||||
el.textContent = t(key);
|
||||
};
|
||||
if (!isDevicePicker) {
|
||||
setI18n('.display-picker-title [data-role="lead"]', 'displays.picker.title.lead');
|
||||
setI18n('.display-picker-title__accent', 'displays.picker.title.accent');
|
||||
}
|
||||
setI18n('.display-picker-eyebrow [data-role="label"]', 'displays.picker.eyebrow.label');
|
||||
setI18n(
|
||||
'.display-picker-eyebrow__channel',
|
||||
isDevicePicker ? 'displays.picker.eyebrow.channel.device' : 'displays.picker.eyebrow.channel',
|
||||
);
|
||||
setI18n(
|
||||
'.display-picker-foot [data-role="select"]',
|
||||
isDevicePicker ? 'displays.picker.foot.select.device' : 'displays.picker.foot.select',
|
||||
);
|
||||
|
||||
// Render synchronously *before* activating the modal so the first frame
|
||||
// shows final content (or a spinner) instead of stale layout from the
|
||||
// previous open.
|
||||
if (_pickerEngineType) {
|
||||
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
|
||||
renderDisplayPickerLayout(_cachedDisplays);
|
||||
} else {
|
||||
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||
}
|
||||
|
||||
lightbox.classList.add('active');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// Always fetch fresh when engine type is specified (different list each time)
|
||||
if (_pickerEngineType) {
|
||||
_fetchAndRenderEngineDisplays(_pickerEngineType);
|
||||
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
|
||||
renderDisplayPickerLayout(_cachedDisplays);
|
||||
} else {
|
||||
const canvas = document.getElementById('display-picker-canvas')!;
|
||||
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||
displaysCache.fetch().then(displays => {
|
||||
if (displays && displays.length > 0) {
|
||||
renderDisplayPickerLayout(displays);
|
||||
} else {
|
||||
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// Kick off async fetches after activation; spinner is already in place.
|
||||
if (_pickerEngineType) {
|
||||
_fetchAndRenderEngineDisplays(_pickerEngineType);
|
||||
} else if (!_cachedDisplays || _cachedDisplays.length === 0) {
|
||||
displaysCache.fetch().then(displays => {
|
||||
if (displays && displays.length > 0) {
|
||||
renderDisplayPickerLayout(displays);
|
||||
} else {
|
||||
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { ICON_HEART, ICON_EXTERNAL_LINK, ICON_X, ICON_GITHUB, ICON_HELP } from '../core/icons.ts';
|
||||
import { ICON_HEART, ICON_X, ICON_GITHUB, ICON_HELP } from '../core/icons.ts';
|
||||
|
||||
// ─── Config ─────────────────────────────────────────────────
|
||||
|
||||
@@ -61,40 +61,53 @@ export function snoozeDonation(): void {
|
||||
_hideBanner();
|
||||
}
|
||||
|
||||
/** Render the About panel content in settings modal. */
|
||||
/** Render the About panel content in settings modal.
|
||||
* Uses the Lumenworks rack-panel + about-hero pattern from the
|
||||
* settings-modal-redesign mockup: a channel-coded .ds-section
|
||||
* wrapping a centered hero with mark, name, version pill, tagline,
|
||||
* and external-link buttons. */
|
||||
export function renderAboutPanel(): void {
|
||||
const container = document.getElementById('about-panel-content');
|
||||
if (!container) return;
|
||||
|
||||
const version = document.getElementById('version-number')?.textContent || '';
|
||||
|
||||
let links = '';
|
||||
const version = document.getElementById('version-number')?.textContent?.trim() || '';
|
||||
|
||||
const linkButtons: string[] = [];
|
||||
if (_repoUrl) {
|
||||
links += `<a href="${_repoUrl}" target="_blank" rel="noopener" class="about-link">
|
||||
${ICON_GITHUB}
|
||||
<span>${t('donation.view_source')}</span>
|
||||
${ICON_EXTERNAL_LINK}
|
||||
</a>`;
|
||||
linkButtons.push(
|
||||
`<a href="${_repoUrl}" target="_blank" rel="noopener" class="btn">
|
||||
${ICON_GITHUB}
|
||||
<span>${t('donation.view_source')}</span>
|
||||
</a>`,
|
||||
);
|
||||
}
|
||||
|
||||
if (_donateUrl) {
|
||||
links += `<a href="${_donateUrl}" target="_blank" rel="noopener" class="about-link about-link-donate">
|
||||
${ICON_HEART}
|
||||
<span>${t('donation.about_donate')}</span>
|
||||
${ICON_EXTERNAL_LINK}
|
||||
</a>`;
|
||||
linkButtons.push(
|
||||
`<a href="${_donateUrl}" target="_blank" rel="noopener" class="btn">
|
||||
${ICON_HEART}
|
||||
<span>${t('donation.about_donate')}</span>
|
||||
</a>`,
|
||||
);
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="about-section">
|
||||
<div class="about-logo">${ICON_HEART}</div>
|
||||
<h3 class="about-title">${t('donation.about_title')}</h3>
|
||||
${version ? `<span class="about-version">${version}</span>` : ''}
|
||||
<p class="about-text">${t('donation.about_opensource')}</p>
|
||||
${links ? `<div class="about-links">${links}</div>` : ''}
|
||||
<p class="about-license">${t('donation.about_license')}</p>
|
||||
</div>
|
||||
<section class="ds-section" data-ch="amber">
|
||||
<div class="ds-section-body">
|
||||
<div class="about-hero">
|
||||
<div class="about-mark" aria-hidden="true">L</div>
|
||||
<div class="about-name">${t('donation.about_title')}</div>
|
||||
${version ? `<div class="about-version">${version}</div>` : ''}
|
||||
<div class="about-tag">${t('donation.about_opensource')}</div>
|
||||
<div class="about-author">
|
||||
${t('donation.about_author')} <strong>Alexei Dolgolyov</strong>
|
||||
<span class="about-author-sep">·</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
</div>
|
||||
${linkButtons.length ? `<div class="about-links">${linkButtons.join('')}</div>` : ''}
|
||||
<div class="about-license">${t('donation.about_license')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { IconSelect, type IconSelectItem } from '../core/icon-select.ts';
|
||||
import {
|
||||
@@ -540,34 +541,54 @@ export function testGameConnection() {
|
||||
// ── Card renderer ──
|
||||
|
||||
export function createGameIntegrationCard(gi: GameIntegration): string {
|
||||
const adapterIcon = getGameAdapterIcon(gi.adapter_type);
|
||||
const adapterName = _cachedGameAdapters.find(a => a.adapter_type === gi.adapter_type)?.display_name || gi.adapter_type;
|
||||
const enabledClass = gi.enabled ? 'gi-status-active' : 'gi-status-inactive';
|
||||
const enabledLabel = gi.enabled ? t('game_integration.status.active') : t('game_integration.status.inactive');
|
||||
const mappingCount = gi.event_mappings?.length || 0;
|
||||
const isEnabled = !!gi.enabled;
|
||||
|
||||
return wrapCard({
|
||||
type: 'template-card',
|
||||
dataAttr: 'data-gi-id',
|
||||
id: gi.id,
|
||||
removeOnclick: `deleteGameIntegration('${gi.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="template-card-header">
|
||||
<div class="template-name" title="${escapeHtml(gi.name)}">${adapterIcon} ${escapeHtml(gi.name)}</div>
|
||||
</div>
|
||||
${gi.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(gi.description)}</div>` : ''}
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('game_integration.adapter')}">${ICON_GAMEPAD} ${escapeHtml(adapterName)}</span>
|
||||
<span class="stream-card-prop ${enabledClass}" title="${t('game_integration.status')}">${ICON_CIRCLE_DOT} ${enabledLabel}</span>
|
||||
${mappingCount > 0 ? `<span class="stream-card-prop" title="${t('game_integration.mappings')}">${_icon(P.listChecks)} ${mappingCount}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(gi.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showGameEventMonitor('${gi.id}')" title="${t('game_integration.events.monitor')}">${ICON_TEST}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneGameIntegration('${gi.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showGameIntegrationEditor('${gi.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
// Badge: GAME · {ADAPTER} — adapter type compressed to 4-6 chars uppercase
|
||||
const adapterBadge = String(gi.adapter_type).toUpperCase().slice(0, 8);
|
||||
const badgeText = `GAME · ${adapterBadge}`;
|
||||
|
||||
const leds: LedState[] = isEnabled ? ['on'] : ['off'];
|
||||
|
||||
const chips: ModChipOpts[] = [
|
||||
{ icon: ICON_GAMEPAD, text: adapterName, title: t('game_integration.adapter') },
|
||||
{ icon: ICON_CIRCLE_DOT, text: enabledLabel, title: t('game_integration.status'), variant: isEnabled ? 'tag' : 'default' },
|
||||
];
|
||||
if (mappingCount > 0) {
|
||||
chips.push({ icon: _icon(P.listChecks), text: `${mappingCount} ${t('game_integration.mappings') || 'mappings'}`, title: t('game_integration.mappings') });
|
||||
}
|
||||
|
||||
const mod: ModCardOpts = {
|
||||
head: {
|
||||
badge: { text: badgeText },
|
||||
name: gi.name,
|
||||
metaHtml: escapeHtml(`${adapterName} · ${mappingCount} ${t('game_integration.mappings') || 'events'}`),
|
||||
leds,
|
||||
menu: {
|
||||
duplicateOnclick: `cloneGameIntegration('${gi.id}')`,
|
||||
hideOnclick: `toggleCardHidden('game-integrations','${gi.id}')`,
|
||||
deleteOnclick: `deleteGameIntegration('${gi.id}')`,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
desc: gi.description || undefined,
|
||||
chips,
|
||||
},
|
||||
foot: {
|
||||
patchState: isEnabled ? 'live' : 'idle',
|
||||
patchLabel: isEnabled ? 'ARMED' : 'DISARMED',
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: `event.stopPropagation(); showGameEventMonitor('${gi.id}')`, title: t('game_integration.events.monitor') },
|
||||
{ icon: ICON_EDIT, onclick: `showGameIntegrationEditor('${gi.id}')`, title: t('common.edit') },
|
||||
],
|
||||
},
|
||||
running: isEnabled,
|
||||
};
|
||||
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-gi-id', id: gi.id, mod });
|
||||
const tagsHtml = renderTagChips(gi.tags);
|
||||
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
|
||||
}
|
||||
|
||||
// ── CRUD ──
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user