Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ef6ac1317 | |||
| 0980cf4dde | |||
| fdac26b9d9 | |||
| 816a27db73 | |||
| 797b806972 | |||
| 9d4a534ec6 | |||
| 51eebf21d5 | |||
| 9067db2639 | |||
| 233b463ac3 | |||
| de13f44f24 | |||
| 1c9acc5afb | |||
| a56569b02f | |||
| ccf4406349 | |||
| 8aa3a323d6 | |||
| 8e109f32b9 | |||
| 033c1f6a92 | |||
| 0804f54537 | |||
| 66f921c07f | |||
| 80f01d4813 | |||
| b1ee3c3942 | |||
| e0ff40f4f5 | |||
| 3f80ef2101 | |||
| 2bae304107 | |||
| dd415e2813 | |||
| b43e1cf375 | |||
| 56853b7123 | |||
| 70c95d1c09 | |||
| e5a2af9821 | |||
| 539e43195f | |||
| c44bb38c43 | |||
| be2d5e1670 | |||
| 5db6eddcf8 | |||
| a8a4296a56 | |||
| 9ce1dc33bf | |||
| 03d2e6b1f2 |
@@ -191,11 +191,21 @@ jobs:
|
||||
echo "Uploaded: $NAME"
|
||||
}
|
||||
|
||||
# Publish an asset plus its .sha256 sidecar. The in-app update
|
||||
# service refuses to install without a published checksum, so
|
||||
# every artifact needs its hash uploaded alongside.
|
||||
upload_with_sha256() {
|
||||
local FILE="$1"
|
||||
upload_asset "$FILE"
|
||||
(cd "$(dirname "$FILE")" && sha256sum "$(basename "$FILE")" > "$(basename "$FILE").sha256")
|
||||
upload_asset "$FILE.sha256"
|
||||
}
|
||||
|
||||
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
|
||||
[ -f "$ZIP_FILE" ] && upload_asset "$ZIP_FILE"
|
||||
[ -f "$ZIP_FILE" ] && upload_with_sha256 "$ZIP_FILE"
|
||||
|
||||
SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1)
|
||||
[ -f "$SETUP_FILE" ] && upload_asset "$SETUP_FILE"
|
||||
[ -f "$SETUP_FILE" ] && upload_with_sha256 "$SETUP_FILE"
|
||||
|
||||
# ── Linux tarball ──────────────────────────────────────────
|
||||
build-linux:
|
||||
@@ -242,26 +252,34 @@ jobs:
|
||||
run: |
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
upload_asset() {
|
||||
local FILE="$1"
|
||||
local NAME
|
||||
NAME=$(basename "$FILE")
|
||||
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'),''))" 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
echo "Replaced existing asset: $NAME"
|
||||
fi
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$FILE"
|
||||
echo "Uploaded: $NAME"
|
||||
}
|
||||
|
||||
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
|
||||
TAR_NAME=$(basename "$TAR_FILE")
|
||||
|
||||
# Delete existing asset with same name to prevent duplicates on re-run
|
||||
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$TAR_NAME'),''))" 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
echo "Replaced existing asset: $TAR_NAME"
|
||||
if [ -f "$TAR_FILE" ]; then
|
||||
upload_asset "$TAR_FILE"
|
||||
(cd "$(dirname "$TAR_FILE")" && sha256sum "$(basename "$TAR_FILE")" > "$(basename "$TAR_FILE").sha256")
|
||||
upload_asset "$TAR_FILE.sha256"
|
||||
fi
|
||||
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$TAR_NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$TAR_FILE"
|
||||
echo "Uploaded: $TAR_NAME"
|
||||
|
||||
# ── Docker image ───────────────────────────────────────────
|
||||
build-docker:
|
||||
needs: create-release
|
||||
|
||||
@@ -5,9 +5,15 @@ on:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
# Allow manual runs (e.g. to validate after a release commit was skipped).
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# Skip release-publishing commits — version bumps don't affect lint/tests
|
||||
# and the release.yml pipeline is already running. PRs and manual dispatch
|
||||
# always run.
|
||||
if: ${{ github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore: release') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
+13
-2
@@ -62,8 +62,17 @@ htmlcov/
|
||||
logs/
|
||||
*.log.*
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
# Runtime data — anchor to repo root so nested package data dirs
|
||||
# (server/src/ledgrab/data/prebuilt_sounds, game_adapters) are NOT ignored.
|
||||
# An unanchored `data/` rule silently broke the v0.4.2 release by keeping
|
||||
# shipped sound assets out of the CI tag checkout.
|
||||
/data/
|
||||
/server/data/
|
||||
# Defensive: if the server is launched from server/src/ (uncommon path),
|
||||
# its relative `data/` dir resolves to server/src/data/. Templates now
|
||||
# live in SQLite, so any *.json that lands here is stale runtime export
|
||||
# and must not be committed.
|
||||
/server/src/data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.json.bak
|
||||
@@ -86,3 +95,5 @@ tmp/
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,3 +104,42 @@ Do NOT commit code that fails linting or tests. Fix the issues first.
|
||||
- Follow existing code style and patterns
|
||||
- Update documentation when changing behavior
|
||||
- Never make commits or pushes without explicit user approval
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
|
||||
+44
-10
@@ -1,18 +1,42 @@
|
||||
## v0.4.1 (2026-04-22)
|
||||
## v0.6.0 (2026-05-01)
|
||||
|
||||
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
|
||||
- **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
|
||||
- Installer now bundles `cryptography` and `just-playback`, sets the `TCL` environment for Tk, and removes the stale `debug.bat` shim ([4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c))
|
||||
- **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
|
||||
|
||||
#### CI/Build
|
||||
- Scope the Android keystore env correctly and fail loudly when a release build is attempted without a signing key ([35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2))
|
||||
- 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))
|
||||
|
||||
#### Documentation
|
||||
- Drop the stale WLED-rename task and document the Android signing secrets ([a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3))
|
||||
- Remove WLED-specific language from the auto-generated release notes template ([4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d))
|
||||
#### 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))
|
||||
|
||||
---
|
||||
|
||||
@@ -21,9 +45,19 @@
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c) | fix(installer): bundle cryptography + just-playback, set TCL env, clean stale debug.bat | alexei.dolgolyov |
|
||||
| [a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3) | docs(release): drop stale WLED-rename task, document android signing secrets | alexei.dolgolyov |
|
||||
| [35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2) | ci(android): fix keystore env scoping, fail loudly on release without key | alexei.dolgolyov |
|
||||
| [4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d) | docs(release): drop WLED-specific language from auto-generated release notes | alexei.dolgolyov |
|
||||
| [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,319 @@
|
||||
# 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.
|
||||
|
||||
- [x] Backend storage: `shutdown_action` in `db.settings` (`"stop_targets"` default | `"nothing"`)
|
||||
- [x] Backend route: `GET/PUT /api/v1/system/shutdown-action` in `system_settings.py`
|
||||
- [x] Backend schema: `ShutdownActionResponse/Request` in `schemas/system.py`
|
||||
- [x] Backend wiring: lifespan shutdown in `main.py` reads action, passes `restore_devices` flag to `processor_manager.stop_all()`
|
||||
- [x] `processor_manager.stop_all(restore_devices: bool = True)` — when False, calls public `proc.cancel_task()` (defined on `TargetProcessor`) which awaits cancellation without restoring device state; skips `_restore_device_idle_state` loop. No reach into private `_task` attribute.
|
||||
- [x] Frontend: hidden `<select>` + IconSelect in `settings.html` General tab (icons via `ICON_SQUARE` / `ICON_CIRCLE` from `core/icons.ts`)
|
||||
- [x] Frontend: load/save handlers in `features/settings.ts`, wired into `openSettingsModal()`
|
||||
- [x] i18n: en / ru / zh keys for label, hint, item descriptions
|
||||
- [ ] Real-hardware test pending — verify that "nothing" actually leaves a WLED + a serial device on the last frame after `Ctrl+C`/SIGTERM.
|
||||
|
||||
## WebUI Redesign — "Lumenworks" Studio-Console Aesthetic
|
||||
|
||||
Full-app UI/UX refresh. Design direction committed to by user 2026-04-24.
|
||||
Mockup lives at [server/docs/ui-redesign-mockup.html](server/docs/ui-redesign-mockup.html).
|
||||
Phases are independent and CSS-only where possible — backend untouched.
|
||||
|
||||
### Phase 1 — Design tokens & font embed
|
||||
|
||||
- [x] Embed variable fonts (`server/src/ledgrab/static/fonts/`):
|
||||
Manrope (latin + latin-ext + cyrillic + cyrillic-ext),
|
||||
JetBrains Mono (same 4 subsets),
|
||||
Big Shoulders Display (latin + latin-ext). Total +201 KB gzipped,
|
||||
served via `unicode-range` so only latin paints on first load.
|
||||
- [x] `fonts.css` — declare `@font-face` entries for all new families with
|
||||
proper `unicode-range` subsetting; keep DM Sans + Orbitron registered
|
||||
for legacy-token callers during migration.
|
||||
- [x] `base.css` — add additive Lumenworks tokens:
|
||||
`--font-display/--font-brand/--font-body`, `--lux-r-*`, `--lux-hairline`,
|
||||
`--lux-rule`. Both `[data-theme="dark"]` and `[data-theme="light"]`
|
||||
define `--lux-bg-0…3`, `--lux-line/-bold`, `--lux-ink/-dim/-mute/-faint`,
|
||||
`--ch-signal/-cyan/-magenta/-amber/-coral/-violet`, `--lux-signal-glow`,
|
||||
`--lux-shadow-rack`. Existing tokens untouched — no visual regression.
|
||||
|
||||
### Phase 2 — Shell (header → transport bar + channel-strip sidebar)
|
||||
|
||||
- [x] `index.html` — `.tab-bar` moved out of `<header>` into a new
|
||||
`<aside class="sidebar">`; wrapped content in `.app-body` 2-col grid
|
||||
(sidebar | main). `.transport-center` section added between
|
||||
`.header-title` and `.header-toolbar` with a placeholder `.transport-status`
|
||||
chip ("Ready" → "Armed · N live" wired in Phase 3). All tab-button IDs,
|
||||
`data-tab` attributes, and `onclick="switchTab(…)"` handlers preserved.
|
||||
- [x] `layout.css` — `<header>` rebuilt as the transport bar: 3-column grid
|
||||
(brand | center | toolbar), 60 px fixed height, sticky, gradient bottom
|
||||
rule with channel-color wash. `.header-title::before/::after` render
|
||||
the glowing LED brand mark; `#server-status` repositioned as the LED
|
||||
core pip. `#server-version` restyled as a mono-type console badge.
|
||||
- [x] `sidebar.css` (new) — vertical channel-strip navigation. Active tab
|
||||
gets a glowing left stripe + radial tint. `.sidebar-foot` contains
|
||||
a `.cpu-meter` plate with two live bars (Load, FPS) ready to be
|
||||
JS-bound in Phase 3. Collapses to a 56 px icon rail at ≤1100 px;
|
||||
hides entirely at ≤600 px via `display: contents` so `.tab-bar`
|
||||
falls through to `mobile.css`'s fixed-bottom strip unchanged.
|
||||
- [x] `all.css` — new sidebar import after layout.
|
||||
- [x] `base.css` — body font-family switched to `var(--font-body)` which
|
||||
resolves to Manrope (with DM Sans + system fallbacks). Added
|
||||
`font-feature-settings` for stylistic set + alternate 1.
|
||||
- [x] Locale additions: `sidebar.workspaces`, `sidebar.load`, `sidebar.fps`,
|
||||
`transport.status.ready`, `transport.status.armed` in en/ru/zh.
|
||||
- [x] Tutorial + auth selectors (`header .header-title`, `#tab-btn-*`,
|
||||
`.tab-bar` querySelector, `a.header-link[href="/docs"]`, onclick
|
||||
markers on theme/settings/search) all survive the move.
|
||||
- [ ] JS: bind `.cpu-meter` + `.transport-status` chip to existing
|
||||
`performance` WebSocket / poller. Done as part of Phase 3.
|
||||
- [ ] Tablet-range visual polish pass once other phases render (some tabs
|
||||
currently have their own internal sticky headers that may overlap
|
||||
the transport bar on narrow viewports).
|
||||
|
||||
### Phase 3 — Dashboard hero + module redesign
|
||||
|
||||
- [x] `cards.css` — `.card` gets rack-module treatment: channel stripe on
|
||||
left edge (color-coded via `data-card-type` + `.ch-*` utility classes),
|
||||
`::after` corner bracket in top-right, mono-typed metric labels
|
||||
planned for Phase 4. Running cards glow the stripe brighter + emit a
|
||||
`signalFlow` keyframe strip along the bottom edge.
|
||||
- [x] Removed the `@property --border-angle` rotating conic-gradient border
|
||||
(retired the WebKit mask workaround + light-theme variant + fallback
|
||||
for `@supports not (mask-composite: exclude)`). Replaced with the
|
||||
signal-flow strip — one animated linear-gradient on a 2 px line, no
|
||||
GPU layer compositing per card.
|
||||
- [x] `dashboard.css` — `.dashboard-target` rows pick up the same channel
|
||||
stripe + signal-flow treatment. Section headers now use mono caps
|
||||
with a channel-green underline accent. Metric values use mono with
|
||||
tabular numerics; labels use silkscreened micro-caps.
|
||||
- [x] Skeleton-card rewritten: left hairline + corner bracket so it reads
|
||||
as "loading module" instead of a generic flashing block.
|
||||
`skeletonShimmer` gradient replaces the old opacity-pulse on
|
||||
`--text-color`.
|
||||
- [x] `_updateSidebarMeter` binds CPU% (Load) and app-CPU share (FPS)
|
||||
to the sidebar meter plate on every perf poll.
|
||||
- [x] `_updateTransportStatus` updates the transport chip ("Ready" →
|
||||
"Armed · N live") whenever the dashboard's running-target set is
|
||||
recomputed.
|
||||
- [ ] `.hero` 4-cell readout row (Active Patches / Throughput / CPU /
|
||||
Latency + inline sparklines) — CSS tokens + layout are ready; HTML
|
||||
render deferred until the dashboard JS is refactored to emit it
|
||||
(Phase 3b, non-blocking).
|
||||
|
||||
### Phase 4 — Other tabs adopt module language
|
||||
|
||||
- [x] `tree-nav.css` — trigger pill gets a channel stripe on its left edge
|
||||
(glows + widens when open). Trigger title uses mono-uppercase with
|
||||
wide letter-spacing. Dropdown panel has a gradient channel-accent
|
||||
rule across its top edge. Group headers use silkscreened micro-caps
|
||||
with a small square marker instead of the old bold-uppercase. Active
|
||||
leaf has a pulsing LED pip on the left and a channel tint behind it.
|
||||
Count badges switched to mono tabular-nums in 2-px-radius pills.
|
||||
- [x] `.subtab-section-header` — channel-green underline accent + mono
|
||||
micro-caps. Consistent with the dashboard-section pattern so the
|
||||
whole app shares one section-header language.
|
||||
- [x] `.stream-tab-btn` sub-tabs — mono uppercase with wide tracking,
|
||||
active tab shows channel-green underline + glowing count badge.
|
||||
- [x] `.perf-chart-card` — channel stripe on the left (replaces old
|
||||
`border-top` accent). Per-metric accents swapped to channel palette
|
||||
(`--ch-coral` for CPU, `--ch-violet` for RAM, `--ch-signal` for GPU,
|
||||
`--ch-amber` for temp). Corner bracket added. Metric values pick up
|
||||
`tabular-nums` + a soft glow.
|
||||
- [x] `cards.css` — channel-color mapping extended to attributes the JS
|
||||
already emits (`data-target-id` → green, `data-stream-id` → cyan,
|
||||
`data-audio-source-id` → magenta, `data-automation-id` /
|
||||
`data-scene-id` → violet). No JS changes required; cards pick up
|
||||
their correct stripe automatically on the Targets/Sources/Automations
|
||||
tabs.
|
||||
- [x] Graph editor — toolbar gets a gradient background + hairline +
|
||||
rack shadow + backdrop blur. Canvas and nodes untouched.
|
||||
- [x] `.template-card` — Lumenworks treatment (channel stripe on left,
|
||||
corner bracket top-right, hairline border, hover lift + stripe
|
||||
glow). Brings Inputs (streams / capture / pp / cspt / pattern
|
||||
templates) and Integrations (HA / MQTT / weather / value /
|
||||
sync-clock / game-integration cards) up to the same visual
|
||||
language as `.card` and `.dashboard-target`.
|
||||
- [x] `cards.css` — channel mapping extended to `.template-card`.
|
||||
Direct attr hooks for `data-stream-id`/`data-template-id`/`data-pp-template-id`
|
||||
(cyan), `data-cspt-id`/`data-pattern-template-id` (signal),
|
||||
`data-audio-template-id`/`data-apt-id` (magenta). Section-scoped
|
||||
hooks via `[data-card-section="…"]` for cards that share a
|
||||
generic `data-id` (HA / MQTT / weather / value → cyan;
|
||||
game-integrations → amber; sync-clocks → violet; HA-light-targets
|
||||
→ signal). No JS changes — uses the section markup `CardSection`
|
||||
already emits.
|
||||
- [x] Graph editor nodes — body fill `--lux-bg-1` with hairline stroke,
|
||||
hover bold-line, selected/running stroke `--ch-signal` with
|
||||
drop-shadow glow. Title font switched from DM Sans to
|
||||
`--font-display`; subtitle to mono uppercase wide-tracking.
|
||||
Port-drop-target glow recoloured to `--ch-signal`. Port labels
|
||||
adopt the mono caption treatment. Grid dots use `--lux-line`.
|
||||
Running gradient stops switched from `--primary-color`/`--success-color`
|
||||
to channel palette (signal → cyan → signal).
|
||||
|
||||
### Phase 5 — Modal restyle
|
||||
|
||||
- [x] `modal.css` — backdrop gains a radial dim + 6 px blur for stronger
|
||||
separation. `.modal-content` gets a gradient background + hairline +
|
||||
deep rack shadow. Channel-accent rule across the top edge driven by
|
||||
`--modal-ch` (per-modal override). Corner bracket bottom-right on
|
||||
desktop. `.modal-header` gains a vertical channel-color stripe to
|
||||
the left of the title; `.modal-footer` picks up a hairline divider.
|
||||
- [x] Per-modal channel mapping by modal ID:
|
||||
- Target editors → green
|
||||
- Input/Source editors → cyan
|
||||
- Audio editors → magenta
|
||||
- Automation / Scene / Game editors → violet
|
||||
- Settings / API key / Setup / Notifications → amber
|
||||
- Confirm dialog → coral
|
||||
- [x] `components.css` — inputs use hairline borders, tabular-nums mono
|
||||
for `input[type="number"]`, channel-green focus ring + glow. Buttons
|
||||
use mono-uppercase type, signal-glow on primary, coral-glow on
|
||||
danger. `<select>` audit deferred (project already enforces via
|
||||
CLAUDE.md rule + IconSelect/EntitySelect wrappers).
|
||||
|
||||
### Phase 6 — Mobile dedicated shell
|
||||
|
||||
- [x] `mobile.css` (existing file, not forked) — fixed-bottom `.tab-bar`
|
||||
promoted to full Lumenworks treatment: gradient background + hairline
|
||||
divider at top + channel-accent rule matching the transport-bar
|
||||
bottom. Active tab gets an LED pip above the icon and a channel-tint
|
||||
background. Tab labels + badges use mono uppercase to match the
|
||||
rest of the app. Phone (≤600 px): modal corner-bracket hidden
|
||||
(fullscreen modals), modal-header stripe slimmed to 18 px.
|
||||
- [x] Phase 2's layout.css already strips the transport-center on phones
|
||||
and collapses the sidebar via `display: contents`, so the mobile
|
||||
shell automatically routes the tab-bar to the bottom without a
|
||||
separate JS hook.
|
||||
- [WONTDO] Fork into `mobile-shell.css` — keeping changes in `mobile.css`
|
||||
since the cascade was already organized by viewport. A rename adds
|
||||
churn without improving maintainability.
|
||||
|
||||
### Phase 7 — Microcopy + retire legacy
|
||||
|
||||
- [x] Locale rename: `targets.title` + `dashboard.section.targets` →
|
||||
"Channels" (en) / "Каналы" (ru) / "通道" (zh);
|
||||
`streams.title` → "Inputs" / "Входы" / "输入".
|
||||
Automations kept as-is (Automations + Scenes is a meaningful
|
||||
distinction; "Patches" would conflate them). Internal tab keys
|
||||
(`dashboard` / `automations` / `targets` / `streams` / `integrations`
|
||||
/ `graph`) unchanged so no JS or localStorage migration needed.
|
||||
- [x] Ambient WebGL background — default is already `off`; kept the
|
||||
toggle button and localStorage preference so users who want the
|
||||
shader can turn it on. No entry-point change needed: `data-bg-anim`
|
||||
is initialized from localStorage with `off` fallback.
|
||||
- [DEFERRED] Delete DM Sans + legacy color tokens — would cascade through
|
||||
every file that reads `--primary-color` / `--text-color` etc. Safer
|
||||
as a separate cleanup PR after the new design has soaked.
|
||||
- [WONTDO] Delete `mobile.css` — Phase 6 kept the filename.
|
||||
|
||||
## Dashboard Customization
|
||||
|
||||
Per-account dashboard layout — slide-in Customize panel lets users
|
||||
toggle section / perf-cell visibility, reorder via drag, change density,
|
||||
pick presets, and import/export the layout as JSON. Server-synced via
|
||||
`db.get_setting('dashboard_layout')` so settings follow the user.
|
||||
|
||||
- [x] `js/features/dashboard-layout.ts` — schema (open registry of section
|
||||
/ perf-cell keys so v1.1 cards slot in with no migration), defaults,
|
||||
5 built-in presets (Studio/Operator/Showrunner/Diagnostics/TV),
|
||||
localStorage cache + server sync, legacy-key migration from
|
||||
`dashboard_collapsed`, `perfMetricsMode`, `perfChartColor_*`.
|
||||
- [x] `api/routes/preferences.py` — `GET/PUT/DELETE
|
||||
/api/v1/preferences/dashboard-layout`. Treats payload as opaque
|
||||
(frontend owns the schema); validates only that body is an object
|
||||
with a numeric `version`. 6 pytest tests in
|
||||
`tests/test_preferences_api.py` cover round-trip, default-empty,
|
||||
validation, delete, and unknown-field passthrough.
|
||||
- [x] `js/features/dashboard.ts` — sections rendered into a fragment map,
|
||||
then assembled in layout-driven order; perf section stays pinned
|
||||
top (chart-persistence reasons) but its visibility is layout-
|
||||
driven. Layout-change subscription invalidates the in-place-update
|
||||
optimization so density / order / visibility changes always
|
||||
rebuild section HTML.
|
||||
- [x] `js/features/perf-charts.ts` — `renderPerfSection()` iterates
|
||||
`getOrderedPerfCells()`; existing legacy `setPerfMode` writes
|
||||
through to the layout so the global toggle and the customize
|
||||
panel stay in sync.
|
||||
- [x] `js/features/dashboard-customize.ts` + `css/dashboard-customize.css`
|
||||
— slide-in panel, hand-rolled HTML5 drag-and-drop reorder, ↑/↓
|
||||
buttons for keyboard / TV remote, debounced (300 ms) autosave,
|
||||
live preview while open. Reset / export / import actions.
|
||||
- [x] i18n keys for `dashboard.customize.*` in en/ru/zh.
|
||||
- [ ] (v1.1) Audio meters section — peak / RMS / BPM bars per audio
|
||||
source. Schema key `audio-meters` already reserved.
|
||||
- [ ] (v1.1) Alerts section — quiet by default, loud on issues.
|
||||
Reserved key `alerts`.
|
||||
- [ ] (v1.1) Live LED preview strip per running device. Reserved
|
||||
key `led-preview`.
|
||||
- [ ] (v1.1) Source thumbnails grid (1 fps multiviewer). Reserved
|
||||
key `source-thumbs`.
|
||||
- [ ] (v1.2) Pinned section (user-curated mix of targets / scenes /
|
||||
devices). Reserved key `pinned`.
|
||||
- [ ] (v1.2) Patch/flow map — read-only mini graph of routing.
|
||||
Reserved key `flow`.
|
||||
|
||||
## BLE LED Controller Support (SP110E / Triones / Zengge / Govee)
|
||||
|
||||
Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming.
|
||||
|
||||
@@ -40,7 +40,7 @@ android {
|
||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||
// sideload updates silently refused to install.
|
||||
versionCode = ledgrabVersionCode
|
||||
versionName = "0.4.1"
|
||||
versionName = "0.6.0"
|
||||
|
||||
ndk {
|
||||
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
||||
|
||||
@@ -69,6 +69,16 @@ copy_app_files() {
|
||||
# Clean up source maps and __pycache__
|
||||
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
|
||||
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Patch the fallback version in the bundled __init__.py. Bundled installs
|
||||
# strip ledgrab-*.dist-info from site-packages, so importlib.metadata
|
||||
# falls back to this literal at runtime — and a stale literal is what
|
||||
# silently shipped v0.4.2 reporting "0.3.0" in the WebUI.
|
||||
local bundled_init="$APP_DIR/src/ledgrab/__init__.py"
|
||||
if [ -f "$bundled_init" ] && [ -n "${VERSION_CLEAN:-}" ]; then
|
||||
sed -i "s/_FALLBACK_VERSION = \"[^\"]*\"/_FALLBACK_VERSION = \"${VERSION_CLEAN}\"/" "$bundled_init"
|
||||
echo " Patched _FALLBACK_VERSION -> ${VERSION_CLEAN}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Site-packages cleanup ────────────────────────────────────
|
||||
|
||||
@@ -196,6 +196,17 @@ New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
|
||||
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Patch the fallback version in the bundled __init__.py so the WebUI always
|
||||
# reports the release version — the installer strips ledgrab-*.dist-info from
|
||||
# site-packages (above), so importlib.metadata falls back to this literal.
|
||||
$bundledInit = Join-Path $srcDest "ledgrab\__init__.py"
|
||||
if (Test-Path $bundledInit) {
|
||||
$initContent = Get-Content $bundledInit -Raw
|
||||
$patched = [regex]::Replace($initContent, '_FALLBACK_VERSION\s*=\s*"[^"]*"', "_FALLBACK_VERSION = `"$VersionClean`"")
|
||||
Set-Content -Path $bundledInit -Value $patched -NoNewline
|
||||
Write-Host " Patched _FALLBACK_VERSION -> $VersionClean"
|
||||
}
|
||||
|
||||
# ── Create launcher ────────────────────────────────────────────
|
||||
|
||||
Write-Host "[8/8] Creating launcher..."
|
||||
|
||||
+4
-1
@@ -162,8 +162,11 @@ Section "Desktop shortcut" SecDesktop
|
||||
SectionEnd
|
||||
|
||||
Section "Start with Windows" SecAutostart
|
||||
; Pass --autostart so the VBS sets LEDGRAB_AUTOSTART=1 and the app suppresses
|
||||
; the browser auto-open on Windows login. Manual launches (desktop / start
|
||||
; menu) don't pass the arg, so they keep opening the WebUI tab.
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}" --autostart' \
|
||||
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
||||
# Refactor Plan: Per-Provider Typed Device Configs
|
||||
|
||||
**Status:** Planned, not started.
|
||||
**Target branch:** `refactor/device-typed-configs`
|
||||
**Intended executor:** Sonnet agent (one phase per invocation; human review between phases).
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the flat [`DeviceInfo`](../../server/src/ledgrab/core/processing/target_processor.py) dataclass (and the `**kwargs`-based `LEDDeviceProvider.create_client(url, **kwargs)` contract) with a **discriminated union of per-provider config dataclasses**. Each provider owns its config type and reads typed fields instead of guessing kwargs.
|
||||
|
||||
## Motivation
|
||||
|
||||
Current pain points:
|
||||
|
||||
- [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) unpacks ~21 fields by hand into `create_led_client(**kwargs)`.
|
||||
- Every provider's `create_client` starts with `kwargs.get("x", default)` — no type safety, no IDE hints, no way to know at a glance which fields a provider actually uses.
|
||||
- Adding a new per-device-type field requires threading it through `Device` → `DeviceInfo` → `_DEVICE_FIELD_DEFAULTS` → call-site unpacking → kwargs bag → provider.
|
||||
- Fields leak across device types (a WLED device carries `ble_govee_key=""` at runtime for no reason).
|
||||
|
||||
## Scope guardrails
|
||||
|
||||
- **Storage schema (SQLite) unchanged.** Columns stay, dead-for-this-type fields stay, no destructive migration.
|
||||
- **Frontend HTML/TS unchanged in phases 1-4.** It already branches on `device_type` with show/hide logic. Frontend changes are deferred to Phase 5.
|
||||
- **API schemas are last.** Phase 5 converts `DeviceCreate`/`DeviceUpdate`/`DeviceResponse` to a Pydantic v2 discriminated union. This is the only breaking external change and can be deferred indefinitely if needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Config hierarchy (foundation, non-breaking)
|
||||
|
||||
### Create
|
||||
|
||||
**File:** `server/src/ledgrab/core/devices/device_config.py`
|
||||
|
||||
Pattern:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Literal, Optional, Union
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BaseDeviceConfig:
|
||||
device_id: str
|
||||
device_url: str
|
||||
led_count: int
|
||||
software_brightness: int = 255
|
||||
test_mode_active: bool = False
|
||||
auto_shutdown: bool = False
|
||||
rgbw: bool = False
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WLEDConfig(BaseDeviceConfig):
|
||||
device_type: Literal["wled"] = "wled"
|
||||
use_ddp: bool = False
|
||||
|
||||
# ... one @dataclass(frozen=True) per provider
|
||||
```
|
||||
|
||||
### Config field inventory
|
||||
|
||||
Base: `device_id`, `device_url`, `led_count`, `software_brightness`, `test_mode_active`, `auto_shutdown`, `rgbw`.
|
||||
|
||||
| Config | Extra fields beyond Base |
|
||||
| -------------- | ------------------------ |
|
||||
| WLEDConfig | `use_ddp: bool = False` |
|
||||
| AdalightConfig | `baud_rate: Optional[int] = None` |
|
||||
| AmbiLEDConfig | `baud_rate: Optional[int] = None` |
|
||||
| DMXConfig | `dmx_protocol`, `dmx_start_universe`, `dmx_start_channel` |
|
||||
| ESPNowConfig | `baud_rate`, `espnow_peer_mac`, `espnow_channel` |
|
||||
| HueConfig | `hue_username`, `hue_client_key`, `hue_entertainment_group_id` |
|
||||
| SPIConfig | `spi_speed_hz`, `spi_led_type` |
|
||||
| ChromaConfig | `chroma_device_type` |
|
||||
| GameSenseConfig| `gamesense_device_type` |
|
||||
| BLEConfig | `ble_family`, `ble_govee_key` |
|
||||
| GroupConfig | `group_mode`, `group_device_ids` (**no `device_store` here** — see Phase 2) |
|
||||
| OpenRGBConfig | `zone_mode` |
|
||||
| MockConfig | `send_latency_ms: int = 0` |
|
||||
| DemoConfig | `send_latency_ms: int = 0` |
|
||||
| MQTTConfig | (none) |
|
||||
| WSConfig | (none) |
|
||||
| USBHIDConfig | (none — `hid_usage_page` is parsed from the URL, not config) |
|
||||
|
||||
```python
|
||||
DeviceConfig = Union[
|
||||
WLEDConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, ESPNowConfig,
|
||||
HueConfig, SPIConfig, ChromaConfig, GameSenseConfig, BLEConfig,
|
||||
GroupConfig, MQTTConfig, WSConfig, USBHIDConfig, OpenRGBConfig,
|
||||
MockConfig, DemoConfig,
|
||||
]
|
||||
```
|
||||
|
||||
### Add
|
||||
|
||||
**`Device.to_config() -> DeviceConfig`** in [server/src/ledgrab/storage/device_store.py](../../server/src/ledgrab/storage/device_store.py) (around lines 14-97 where `Device` lives).
|
||||
|
||||
- Dispatches on `self.device_type`.
|
||||
- Constructs the right subclass, pulling only relevant columns.
|
||||
- Ignores columns that don't apply to the type.
|
||||
- This is the **only** place that knows the flat→typed mapping.
|
||||
|
||||
### Do NOT touch in Phase 1
|
||||
|
||||
- Provider signatures (still `create_client(self, url, **kwargs)`).
|
||||
- `create_led_client` factory.
|
||||
- Any call site.
|
||||
- `DeviceInfo` itself.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- New unit test `server/tests/core/devices/test_device_config.py`:
|
||||
- For each provider, build a `Device` with that `device_type`, call `to_config()`, assert right subclass and right fields.
|
||||
- Edge case: extra/irrelevant Device fields must not leak into the wrong config type.
|
||||
- `cd server && ruff check src/ tests/ --fix` — green.
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green (existing tests untouched, new test passes).
|
||||
- `cd server && npx tsc --noEmit` — green (no TS impact this phase, just a sanity check).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 + Phase 3 — Provider API migration + call-site migration (single PR)
|
||||
|
||||
**These must land in one commit** because the provider signature change would otherwise break the 3 call sites immediately.
|
||||
|
||||
### Change the abstract base
|
||||
|
||||
[server/src/ledgrab/core/devices/led_client.py](../../server/src/ledgrab/core/devices/led_client.py):
|
||||
|
||||
```python
|
||||
class LEDDeviceProvider(ABC):
|
||||
@abstractmethod
|
||||
def create_client(self, config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient: ...
|
||||
```
|
||||
|
||||
`ProviderDeps` is a tiny new dataclass:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDeps:
|
||||
device_store: "DeviceStore"
|
||||
# Add future cross-cutting runtime deps here (http_client, etc.)
|
||||
```
|
||||
|
||||
`create_led_client`:
|
||||
|
||||
```python
|
||||
def create_led_client(config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient:
|
||||
return get_provider(config.device_type).create_client(config, deps=deps)
|
||||
```
|
||||
|
||||
### Update every provider (17 files)
|
||||
|
||||
- Narrow signature per provider: e.g. `WLEDDeviceProvider.create_client(self, config: WLEDConfig, *, deps: ProviderDeps)`.
|
||||
- Drop all `kwargs.get("x")` lookups — read typed fields directly.
|
||||
- Providers that don't need `deps` just ignore it.
|
||||
- **GroupDeviceProvider** is the only current consumer of `deps`: reads `deps.device_store`.
|
||||
|
||||
### Call sites (3)
|
||||
|
||||
1. [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) lines ~120-148 — the 21-field unpacking. Replace with:
|
||||
```python
|
||||
config = device.to_config()
|
||||
self._led_client = create_led_client(config, deps=self._provider_deps)
|
||||
```
|
||||
`self._provider_deps` is plumbed in from `ProcessorManager` when the target processor is constructed.
|
||||
2. [server/src/ledgrab/core/processing/device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78 — minimal test-mode client. Build a synthetic config via a helper `_minimal_config_for_test_mode(device)` (keeps just `device_id`, `device_url`, `led_count`, `baud_rate`) and pass it.
|
||||
3. [server/src/ledgrab/core/devices/group_client.py](../../server/src/ledgrab/core/devices/group_client.py) lines 47-70 — child client construction inside the group. Same pattern: `child_config = child_device.to_config()`; pass `deps` through.
|
||||
|
||||
### Delete
|
||||
|
||||
- `DeviceInfo` dataclass in [server/src/ledgrab/core/processing/target_processor.py](../../server/src/ledgrab/core/processing/target_processor.py) lines 71-109.
|
||||
- `ProcessorManager._get_device_info()` and `_DEVICE_FIELD_DEFAULTS` in [server/src/ledgrab/core/processing/processor_manager.py](../../server/src/ledgrab/core/processing/processor_manager.py) lines 230-275 — `Device.to_config()` subsumes this. Verify no other callers via `ast-index usages "_get_device_info"`.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `ast-index search "device_info\."` — no hits in non-test code.
|
||||
- `ast-index search "DeviceInfo"` — no hits outside archival comments.
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all tests pass.
|
||||
- Manual smoke: start server, create a WLED device, start processing, verify LEDs update (or mock output shows frames).
|
||||
- `cd server && ruff check src/ tests/ --fix` — green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Test migration
|
||||
|
||||
Update these files:
|
||||
|
||||
- `server/tests/storage/test_device_store.py` — add `to_config()` cases per device type.
|
||||
- `server/tests/api/routes/test_devices_routes.py` — should be mostly untouched (API schemas still flat until Phase 5).
|
||||
- `server/tests/e2e/test_device_flow.py` — update internal assertions only if they touch `DeviceInfo` directly.
|
||||
- `server/tests/test_group_device.py` — construct child clients with `GroupConfig`.
|
||||
- Any fixture helper that builds a fake `DeviceInfo` — migrate to the right `*Config` subclass.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all green.
|
||||
- Coverage of `device_config.py` and `Device.to_config()` ≥ 90%.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — API discriminated union (OPTIONAL, separate PR)
|
||||
|
||||
**Do not start until Phases 1-4 are merged and stable.** Flag this to the human before beginning. This is the only phase with an externally breaking change.
|
||||
|
||||
### Backend
|
||||
|
||||
[server/src/ledgrab/api/schemas/devices.py](../../server/src/ledgrab/api/schemas/devices.py) — replace flat `DeviceCreate`/`DeviceUpdate` with Pydantic v2 tagged unions:
|
||||
|
||||
```python
|
||||
class WLEDDeviceCreate(BaseModel):
|
||||
device_type: Literal["wled"]
|
||||
name: str
|
||||
url: str
|
||||
led_count: int
|
||||
use_ddp: bool = False
|
||||
# ... base fields only
|
||||
|
||||
DeviceCreate = Annotated[
|
||||
Union[WLEDDeviceCreate, AdalightDeviceCreate, ...],
|
||||
Field(discriminator="device_type"),
|
||||
]
|
||||
```
|
||||
|
||||
Add `model_config = ConfigDict(extra="ignore")` on each union member for **one release cycle** so existing clients (frontend, HAOS integration, curl scripts) that send extra fields don't 422 immediately. Add a deprecation note and tighten to `extra="forbid"` in a follow-up.
|
||||
|
||||
### Frontend
|
||||
|
||||
- [server/src/ledgrab/static/js/features/devices.ts](../../server/src/ledgrab/static/js/features/devices.ts) and related — when building the POST/PATCH body, scope the payload to the selected `device_type` using the show/hide knowledge already in `device-discovery.ts`.
|
||||
- **No plain `<select>` elements** — any new pickers use IconSelect or EntitySelect (see root CLAUDE.md UI rules).
|
||||
|
||||
### Tests
|
||||
|
||||
- Update `test_devices_routes.py` to assert discriminated union rejection of mismatched shapes.
|
||||
- Add round-trip tests: create device of each type via API → fetch → compare fields.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green.
|
||||
- `cd server && npx tsc --noEmit && npm run build` — green.
|
||||
- Manual smoke for at least 3 device types (WLED, DMX, Hue) — create, edit, delete via UI.
|
||||
- HAOS integration still works against the server (spot-check; not automated).
|
||||
|
||||
---
|
||||
|
||||
## Conventions the implementing agent must follow
|
||||
|
||||
- **Project task tracker is `TODO.md`** — check the "Refactor: Per-Provider Device Configs" section, tick boxes as phases land. Do **not** use the `TodoWrite` tool.
|
||||
- **Auto-restart after Python changes.** See [contexts/server-operations.md](../../contexts/server-operations.md).
|
||||
- **No commits without explicit user approval.** Present each phase's diff for review first.
|
||||
- **Pre-commit gate every phase:**
|
||||
- `cd server && ruff check src/ tests/ --fix`
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q`
|
||||
- Phase 5 additionally: `cd server && npx tsc --noEmit && npm run build`
|
||||
- **No plain `<select>`** — Phase 5 uses IconSelect / EntitySelect.
|
||||
- **Android parity:** if you add any new runtime dep to `server/pyproject.toml`, update `android/app/build.gradle.kts` per the root [CLAUDE.md](../../CLAUDE.md) "Android Dependency Sync" section. This refactor should not need any new deps.
|
||||
- **Data migration policy:** storage schema is unchanged, so no JSON-file migration is needed. But if you rename any serialized field during `to_dict`/`from_dict`, add migration logic per the root [CLAUDE.md](../../CLAUDE.md) "Data Migration Policy" section.
|
||||
- **Use `ast-index`** for code search (`ast-index search`, `ast-index usages`, `ast-index callers`, `ast-index class`). Fall back to Grep only for regex/string-literal/comment searches.
|
||||
- **Never run `cd` in Bash.** Use absolute paths or the project-relative `cd server && <cmd>` idiom (one-shot, same invocation).
|
||||
|
||||
## Known risks
|
||||
|
||||
1. **Frozen dataclass + inheritance + defaults** — Python's `@dataclass(frozen=True)` with inheritance requires every subclass field to have a default if any parent field does. Base has defaulted fields. Verify in Phase 1. If it breaks, use `kw_only=True` (Python 3.10+).
|
||||
2. **`use_ddp` origin** — currently inferred from `self._protocol == "ddp"` at the call site, not from Device storage. Options: add a column (schema change, more work), **or** keep inference logic inside `Device.to_config()` (recommended — no schema change). Prefer the latter.
|
||||
3. **Test-mode minimal client** ([device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78) may not have all `BaseDeviceConfig` fields available. Build a synthetic config via a named helper; do not leak the hack into `Device.to_config()`.
|
||||
4. **Group `device_store` import cycle** — `GroupConfig` must **not** hold `device_store` (would pull storage into the config module). `ProviderDeps` is the deliberate cut.
|
||||
5. **BLE optional import** — `BLEDeviceProvider` is conditionally registered (see [led_client.py](../../server/src/ledgrab/core/devices/led_client.py) lines 321-330). Ensure `BLEConfig` still imports cleanly even when `bleak` is absent — put `BLEConfig` in `device_config.py` (not in `ble_provider.py`) so it's always importable.
|
||||
|
||||
## Deliverables per phase
|
||||
|
||||
1. Branch: `refactor/device-typed-configs`.
|
||||
2. One commit per phase, conventional-commit messages:
|
||||
- `refactor(devices): phase 1 — add DeviceConfig hierarchy`
|
||||
- `refactor(devices): phases 2+3 — typed provider signatures + call-site migration`
|
||||
- `refactor(devices): phase 4 — test migration to typed configs`
|
||||
- `refactor(devices): phase 5 — API discriminated union` (separate PR)
|
||||
3. Phase-by-phase diffs presented for user review **before** each commit.
|
||||
4. Final PR body linking all phases, with manual test plan per device type touched.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+48
@@ -14,6 +14,9 @@
|
||||
"marked": "^17.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/big-shoulders-display": "^5.2.5",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
@@ -434,6 +437,33 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/big-shoulders-display": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
|
||||
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/manrope": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
|
||||
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
@@ -704,6 +734,24 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@fontsource-variable/big-shoulders-display": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
|
||||
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
|
||||
"dev": true
|
||||
},
|
||||
"@fontsource-variable/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@fontsource-variable/manrope": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
|
||||
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
|
||||
"dev": true
|
||||
},
|
||||
"@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/big-shoulders-display": "^5.2.5",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ledgrab"
|
||||
version = "0.4.1"
|
||||
version = "0.6.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"
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
|
||||
# Fallback version — kept in sync with pyproject.toml.
|
||||
# Fallback version — kept in sync with pyproject.toml. MUST match the
|
||||
# version declared there on every release. The Windows installer build
|
||||
# (build/build-dist.ps1) also patches this literal to the resolved build
|
||||
# version, so any drift here is corrected for bundled distributions.
|
||||
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
|
||||
# on Android, where the source is included directly via source sets).
|
||||
_FALLBACK_VERSION = "0.3.0"
|
||||
# on Android, where the source is included directly via source sets, or
|
||||
# in the Windows bundle where the installed dist-info is stripped).
|
||||
_FALLBACK_VERSION = "0.4.2"
|
||||
|
||||
try:
|
||||
__version__ = version("ledgrab")
|
||||
|
||||
@@ -12,6 +12,8 @@ import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
def _fix_embedded_tcl_paths() -> None:
|
||||
@@ -54,9 +56,25 @@ def _run_server(server: uvicorn.Server) -> None:
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
|
||||
def _open_browser(port: int, delay: float = 2.0) -> None:
|
||||
"""Open the UI in the default browser after a short delay."""
|
||||
time.sleep(delay)
|
||||
def _wait_for_server(port: int, timeout: float = 30.0, interval: float = 0.25) -> bool:
|
||||
"""Poll /health until the server responds or *timeout* seconds elapse."""
|
||||
url = f"http://localhost:{port}/health"
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urlopen(url, timeout=1) as resp: # noqa: S310 - localhost only
|
||||
if 200 <= resp.status < 500:
|
||||
return True
|
||||
except (URLError, ConnectionError, OSError, TimeoutError):
|
||||
pass
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
def _open_browser(port: int) -> None:
|
||||
"""Open the UI in the default browser once the server is ready."""
|
||||
if not _wait_for_server(port):
|
||||
logger.warning("Server did not become ready in time; opening browser anyway")
|
||||
webbrowser.open(f"http://localhost:{port}")
|
||||
|
||||
|
||||
@@ -65,6 +83,16 @@ def _is_restart() -> bool:
|
||||
return os.environ.get("LEDGRAB_RESTART", "") == "1"
|
||||
|
||||
|
||||
def _is_autostart() -> bool:
|
||||
"""Detect if launched via the Windows autostart shortcut."""
|
||||
return os.environ.get("LEDGRAB_AUTOSTART", "") == "1"
|
||||
|
||||
|
||||
def _should_skip_browser() -> bool:
|
||||
"""Skip auto-opening the browser on restarts and on Windows login autostart."""
|
||||
return _is_restart() or _is_autostart()
|
||||
|
||||
|
||||
def _check_port(host: str, port: int) -> None:
|
||||
"""Exit with a clear message if the port is already in use."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
@@ -102,8 +130,8 @@ def main() -> None:
|
||||
)
|
||||
server_thread.start()
|
||||
|
||||
# Browser after a short delay (skip on restart — user already has a tab)
|
||||
if not _is_restart():
|
||||
# Browser after a short delay (skip on restart and on Windows login autostart)
|
||||
if not _should_skip_browser():
|
||||
threading.Thread(
|
||||
target=_open_browser,
|
||||
args=(config.server.port,),
|
||||
|
||||
@@ -31,6 +31,7 @@ from .routes.game_integration import router as game_integration_router
|
||||
from .routes.audio_processing_templates import router as audio_processing_templates_router
|
||||
from .routes.audio_filters import router as audio_filters_router
|
||||
from .routes.pattern_templates import router as pattern_templates_router
|
||||
from .routes.preferences import router as preferences_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -62,5 +63,6 @@ router.include_router(game_integration_router)
|
||||
router.include_router(audio_processing_templates_router)
|
||||
router.include_router(audio_filters_router)
|
||||
router.include_router(pattern_templates_router)
|
||||
router.include_router(preferences_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""User preferences routes — dashboard layout + notification settings + daylight tz.
|
||||
|
||||
The dashboard layout schema is owned by the frontend (open registry of
|
||||
section/cell keys); the backend treats the value as an opaque JSON blob,
|
||||
validates it's a dict with a `version` field, and persists it under the
|
||||
`dashboard_layout` settings key.
|
||||
|
||||
Notification preferences are validated server-side via Pydantic so the
|
||||
backend can read them when deciding whether to start the background
|
||||
discovery watcher.
|
||||
|
||||
Daylight timezone is a single global IANA tz name shared by every
|
||||
daylight value-source / color-strip-source. Stored as
|
||||
``{"value": "Europe/Berlin"}`` under the ``daylight_timezone`` key, with
|
||||
empty/missing meaning "use system local time".
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import get_database
|
||||
from ledgrab.api.schemas.preferences import NotificationPreferences
|
||||
from ledgrab.core.processing.daylight_settings import (
|
||||
DAYLIGHT_TIMEZONE_KEY,
|
||||
get_daylight_timezone,
|
||||
set_daylight_timezone,
|
||||
)
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
||||
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
||||
|
||||
|
||||
class DaylightTimezonePreference(BaseModel):
|
||||
"""Global IANA timezone applied to every daylight cycle source."""
|
||||
|
||||
timezone: str = Field("", description="IANA timezone name; empty = system local")
|
||||
|
||||
|
||||
def load_notification_preferences(db: Database | None = None) -> NotificationPreferences:
|
||||
"""Read notification prefs, returning defaults when unset or corrupt.
|
||||
|
||||
Used by both the route handler and `main.lifespan` (so the discovery
|
||||
watcher can decide whether to start without going through HTTP).
|
||||
"""
|
||||
if db is None:
|
||||
from ledgrab.api.dependencies import get_database as _get_db
|
||||
|
||||
db = _get_db()
|
||||
raw = db.get_setting(_NOTIFICATION_PREFS_KEY)
|
||||
if not raw:
|
||||
return NotificationPreferences()
|
||||
try:
|
||||
return NotificationPreferences.model_validate(raw)
|
||||
except Exception as e:
|
||||
logger.warning("Stored notification preferences invalid (%s); using defaults", e)
|
||||
return NotificationPreferences()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/dashboard-layout",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_dashboard_layout(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, Any]:
|
||||
"""Read the saved dashboard layout. Returns an empty object when no
|
||||
layout has been saved yet — the frontend falls back to its built-in
|
||||
default in that case."""
|
||||
value = db.get_setting(_DASHBOARD_LAYOUT_KEY)
|
||||
return value if value is not None else {}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/dashboard-layout",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_dashboard_layout(
|
||||
_: AuthRequired,
|
||||
body: dict[str, Any] = Body(...),
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, bool]:
|
||||
"""Save the dashboard layout. The body must be a JSON object with a
|
||||
numeric `version` field; everything else is treated as opaque payload
|
||||
that the frontend will validate on read."""
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=422, detail="Body must be a JSON object")
|
||||
if not isinstance(body.get("version"), int):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Layout must include a numeric 'version' field",
|
||||
)
|
||||
db.set_setting(_DASHBOARD_LAYOUT_KEY, body)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/preferences/dashboard-layout",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def delete_dashboard_layout(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, bool]:
|
||||
"""Delete the saved layout — frontend will revert to the default
|
||||
on next load. Used by the 'Reset' button when the user wants
|
||||
to clear the server-side override entirely."""
|
||||
db.set_setting(_DASHBOARD_LAYOUT_KEY, {})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/notifications",
|
||||
response_model=NotificationPreferences,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_notification_preferences(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> NotificationPreferences:
|
||||
"""Read notification prefs, returning defaults when unset.
|
||||
|
||||
Defaults: device_offline=both, device_online/discovered=snack,
|
||||
device_lost=none, background discovery on, 10 s startup grace,
|
||||
5 s flap debounce.
|
||||
"""
|
||||
return load_notification_preferences(db)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/notifications",
|
||||
response_model=NotificationPreferences,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_notification_preferences(
|
||||
_: AuthRequired,
|
||||
body: NotificationPreferences,
|
||||
db: Database = Depends(get_database),
|
||||
) -> NotificationPreferences:
|
||||
"""Persist the notification prefs. Pydantic enforces channel
|
||||
enum + grace/debounce ranges so a bad client cannot poison
|
||||
the stored value."""
|
||||
db.set_setting(_NOTIFICATION_PREFS_KEY, body.model_dump())
|
||||
logger.info(
|
||||
"Notification preferences updated (background_discovery=%s, " "channels=%s)",
|
||||
body.background_discovery_enabled,
|
||||
body.channels.model_dump(),
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import asyncio
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -92,6 +93,13 @@ def _get_cpu_name() -> str | None:
|
||||
|
||||
_cpu_name: str | None = _get_cpu_name()
|
||||
|
||||
# Captured at first import of this module. Process-wide elapsed time is
|
||||
# the closest the server has to "app start" without instrumenting main.py;
|
||||
# the system module is imported during router setup, before the server
|
||||
# accepts requests, so the drift is negligible. Used by /health to expose
|
||||
# uptime_seconds for the transport-bar ticker.
|
||||
_APP_START_MONOTONIC: float = time.monotonic()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -122,6 +130,7 @@ async def health_check(request: Request):
|
||||
setup_required=setup_required,
|
||||
repo_url=REPO_URL,
|
||||
donate_url=DONATE_URL,
|
||||
uptime_seconds=time.monotonic() - _APP_START_MONOTONIC,
|
||||
)
|
||||
|
||||
|
||||
@@ -316,6 +325,15 @@ def get_system_performance(_: AuthRequired):
|
||||
except Exception as e:
|
||||
logger.debug("NVML query failed: %s", e)
|
||||
|
||||
# Windows has no user-space CPU die temperature source without a kernel
|
||||
# driver. We rely on LibreHardwareMonitor / OpenHardwareMonitor publishing
|
||||
# WMI sensors when the user runs them. When no reading arrives, surface
|
||||
# that explicitly so the dashboard can show a "here's how to enable it"
|
||||
# hint instead of silently hiding the card.
|
||||
cpu_temp_hint_key: str | None = None
|
||||
if thermals.cpu_temp_c is None and platform.system() == "Windows":
|
||||
cpu_temp_hint_key = "dashboard.perf.temp.install_lhm"
|
||||
|
||||
return PerformanceResponse(
|
||||
cpu_name=_cpu_name,
|
||||
cpu_percent=metrics.cpu_percent(),
|
||||
@@ -328,6 +346,7 @@ def get_system_performance(_: AuthRequired):
|
||||
battery_percent=thermals.battery_percent,
|
||||
battery_temp_c=thermals.battery_temp_c,
|
||||
cpu_temp_c=thermals.cpu_temp_c,
|
||||
cpu_temp_hint_key=cpu_temp_hint_key,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ from ledgrab.api.schemas.system import (
|
||||
LogLevelResponse,
|
||||
MQTTSettingsRequest,
|
||||
MQTTSettingsResponse,
|
||||
ShutdownAction,
|
||||
ShutdownActionRequest,
|
||||
ShutdownActionResponse,
|
||||
)
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.storage.database import Database
|
||||
@@ -150,6 +153,55 @@ async def update_external_url(
|
||||
return ExternalUrlResponse(external_url=url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shutdown action setting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_SHUTDOWN_ACTIONS: tuple[str, ...] = ("stop_targets", "nothing")
|
||||
_DEFAULT_SHUTDOWN_ACTION: ShutdownAction = "stop_targets"
|
||||
|
||||
|
||||
def load_shutdown_action(db: Database | None = None) -> ShutdownAction:
|
||||
"""Load the configured shutdown action. Returns the default if unset or corrupt."""
|
||||
if db is None:
|
||||
from ledgrab.api.dependencies import get_database
|
||||
|
||||
db = get_database()
|
||||
data = db.get_setting("shutdown_action")
|
||||
if not data:
|
||||
return _DEFAULT_SHUTDOWN_ACTION
|
||||
value = data.get("action")
|
||||
if value in _VALID_SHUTDOWN_ACTIONS:
|
||||
return value # type: ignore[return-value]
|
||||
return _DEFAULT_SHUTDOWN_ACTION
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/shutdown-action",
|
||||
response_model=ShutdownActionResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def get_shutdown_action(_: AuthRequired, db: Database = Depends(get_database)):
|
||||
"""Get the configured server shutdown action."""
|
||||
return ShutdownActionResponse(action=load_shutdown_action(db))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/system/shutdown-action",
|
||||
response_model=ShutdownActionResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def update_shutdown_action(
|
||||
_: AuthRequired,
|
||||
body: ShutdownActionRequest,
|
||||
db: Database = Depends(get_database),
|
||||
):
|
||||
"""Set what happens to LED targets when the server shuts down."""
|
||||
db.set_setting("shutdown_action", {"action": body.action})
|
||||
logger.info("Shutdown action updated: %s", body.action)
|
||||
return ShutdownActionResponse(action=body.action)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live log viewer WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
@@ -26,6 +26,10 @@ class HealthResponse(BaseModel):
|
||||
)
|
||||
repo_url: str = Field(default="", description="Source code repository URL")
|
||||
donate_url: str = Field(default="", description="Donation page URL")
|
||||
uptime_seconds: float = Field(
|
||||
default=0.0,
|
||||
description="Process uptime in seconds since the server started.",
|
||||
)
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
@@ -98,6 +102,15 @@ class PerformanceResponse(BaseModel):
|
||||
default=None,
|
||||
description="Hottest CPU/SoC thermal zone in °C (null if unsupported)",
|
||||
)
|
||||
cpu_temp_hint_key: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"i18n key for an explainer shown in the Temperature card when "
|
||||
"cpu_temp_c is null and the platform has a known workaround "
|
||||
"(e.g. install LibreHardwareMonitor on Windows). Null on "
|
||||
"platforms where unavailable simply means 'not reported'."
|
||||
),
|
||||
)
|
||||
timestamp: datetime = Field(description="Measurement timestamp")
|
||||
|
||||
|
||||
@@ -191,6 +204,32 @@ class ExternalUrlRequest(BaseModel):
|
||||
external_url: str = Field(default="", description="External base URL. Empty string to clear.")
|
||||
|
||||
|
||||
# ─── Shutdown action schemas ───────────────────────────────────
|
||||
|
||||
|
||||
ShutdownAction = Literal["stop_targets", "nothing"]
|
||||
|
||||
|
||||
class ShutdownActionResponse(BaseModel):
|
||||
"""Current server shutdown action setting."""
|
||||
|
||||
action: ShutdownAction = Field(
|
||||
description=(
|
||||
"What happens to LED targets when the server shuts down. "
|
||||
"`stop_targets` runs the normal stop sequence (per-device "
|
||||
"auto_shutdown decides whether prior state is restored). "
|
||||
"`nothing` skips device-touching teardown — lights freeze on "
|
||||
"their last frame regardless of per-device auto_shutdown."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ShutdownActionRequest(BaseModel):
|
||||
"""Update the server shutdown action setting."""
|
||||
|
||||
action: ShutdownAction = Field(description="New shutdown action.")
|
||||
|
||||
|
||||
# ─── Log level schemas ─────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -770,8 +771,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
|
||||
# ===== LIFECYCLE =====
|
||||
|
||||
async def stop_all(self):
|
||||
"""Stop processing and health monitoring for all targets and devices."""
|
||||
async def stop_all(self, restore_devices: bool = True):
|
||||
"""Stop processing and health monitoring for all targets and devices.
|
||||
|
||||
When ``restore_devices`` is False, processor tasks are cancelled
|
||||
directly instead of going through ``proc.stop()`` (which sends
|
||||
per-device auto_shutdown restore frames), and the global
|
||||
idle-state restore loop is skipped. Used by the "Nothing"
|
||||
shutdown action so lights freeze on their last frame regardless
|
||||
of per-device auto_shutdown.
|
||||
"""
|
||||
await self._metrics_history.stop()
|
||||
await self.stop_health_monitoring()
|
||||
|
||||
@@ -781,18 +790,35 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
if rs.restart_task and not rs.restart_task.done():
|
||||
rs.restart_task.cancel()
|
||||
|
||||
# Stop all processors
|
||||
for target_id, proc in list(self._processors.items()):
|
||||
if proc.is_running:
|
||||
try:
|
||||
await proc.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping target {target_id}: {e}")
|
||||
if restore_devices:
|
||||
# Stop all processors (per-device auto_shutdown decides whether
|
||||
# the prior device state is restored).
|
||||
for target_id, proc in list(self._processors.items()):
|
||||
if proc.is_running:
|
||||
try:
|
||||
await proc.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping target {target_id}: {e}")
|
||||
|
||||
# Restore idle state for devices that have auto-restore enabled
|
||||
# (serial devices already dark from processor close; WLED restored by snapshot)
|
||||
for device_id in self._devices:
|
||||
await self._restore_device_idle_state(device_id)
|
||||
# Restore idle state for devices that have auto-restore enabled
|
||||
# (serial devices already dark from processor close; WLED restored by snapshot)
|
||||
for device_id in self._devices:
|
||||
await self._restore_device_idle_state(device_id)
|
||||
else:
|
||||
# "Nothing" mode: cancel processor capture tasks without sending
|
||||
# restore frames so the LEDs keep displaying the last frame.
|
||||
# ``cancel_task`` (defined on ``TargetProcessor``) awaits the
|
||||
# cancellation so the loop's current iteration completes — no
|
||||
# half-written frame on the wire when the process exits.
|
||||
for target_id, proc in list(self._processors.items()):
|
||||
try:
|
||||
await proc.cancel_task()
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling task for target {target_id}: {e}")
|
||||
logger.info(
|
||||
"Shutdown action 'nothing': skipped device restore for %d target(s)",
|
||||
len(self._processors),
|
||||
)
|
||||
|
||||
# Close any cached idle LED clients (WLED only; serial has no cached clients)
|
||||
for did in list(self._idle_clients):
|
||||
|
||||
@@ -16,6 +16,10 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
|
||||
@@ -65,6 +69,13 @@ class ProcessingMetrics:
|
||||
# Streaming liveness (HTTP probe during DDP)
|
||||
device_streaming_reachable: Optional[bool] = None
|
||||
fps_effective: int = 0
|
||||
# Cumulative LED-payload bytes sent to the device. Aggregated across
|
||||
# all running targets in MetricsHistory to derive a per-second
|
||||
# network throughput sparkline. Counts the color-array payload only;
|
||||
# protocol overhead (DDP/UDP/IP headers) is sub-5 % for any
|
||||
# non-trivial LED count and is intentionally ignored to keep the
|
||||
# counter cheap (`np.ndarray.nbytes`, no per-frame allocation).
|
||||
bytes_sent: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -145,6 +156,32 @@ class TargetProcessor(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
async def cancel_task(self) -> None:
|
||||
"""Cancel the processing task without restoring device state.
|
||||
|
||||
Used by ``ProcessorManager.stop_all(restore_devices=False)`` at
|
||||
server shutdown when the user has chosen "Nothing" — LEDs should
|
||||
keep displaying their last frame, so we skip the per-device
|
||||
``stop()`` path that sends restore frames. We still flip
|
||||
``_is_running`` and await the cancellation so the loop's current
|
||||
iteration completes (no half-written frame on the wire).
|
||||
|
||||
Subclasses with extra non-device cleanup (e.g. live-stream
|
||||
release) may override this; the default just stops the task.
|
||||
"""
|
||||
self._is_running = False
|
||||
task = self._task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
# Log but don't propagate — caller is shutting down.
|
||||
logger.debug("Task raised during cancel_task", exc_info=True)
|
||||
self._task = None
|
||||
|
||||
# ----- Settings -----
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
|
||||
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
||||
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
|
||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||
from ledgrab.storage.audio_source_store import AudioSourceStore
|
||||
from ledgrab.storage.value_source import ValueSource
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
@@ -599,31 +600,61 @@ class DaylightValueStream(ValueStream):
|
||||
speed: float = 1.0,
|
||||
use_real_time: bool = False,
|
||||
latitude: float = 50.0,
|
||||
longitude: float = 0.0,
|
||||
min_value: float = 0.0,
|
||||
max_value: float = 1.0,
|
||||
):
|
||||
from ledgrab.core.processing.daylight_stream import _get_daylight_lut
|
||||
|
||||
self._lut = _get_daylight_lut()
|
||||
self._default_lut = _get_daylight_lut()
|
||||
self._speed = speed
|
||||
self._use_real_time = use_real_time
|
||||
self._latitude = latitude
|
||||
self._longitude = longitude
|
||||
self._min = min_value
|
||||
self._max = max_value
|
||||
self._start_time = time.perf_counter()
|
||||
# Cache: (sr_min, ss_min) → LUT, mirroring DaylightColorStripStream
|
||||
self._lut_cache: Dict[Tuple[int, int], np.ndarray] = {}
|
||||
|
||||
def _resolve_lut(self, day_of_year: Optional[int], utc_offset_hours: float) -> np.ndarray:
|
||||
if day_of_year is None:
|
||||
return self._default_lut
|
||||
from ledgrab.core.processing.daylight_stream import (
|
||||
_build_lut_for_solar_times,
|
||||
_compute_solar_times,
|
||||
)
|
||||
|
||||
sr, ss = _compute_solar_times(
|
||||
self._latitude, self._longitude, day_of_year, utc_offset_hours
|
||||
)
|
||||
key = (int(round(sr * 60)), int(round(ss * 60)))
|
||||
lut = self._lut_cache.get(key)
|
||||
if lut is None:
|
||||
lut = _build_lut_for_solar_times(sr, ss)
|
||||
if len(self._lut_cache) > 8:
|
||||
self._lut_cache.clear()
|
||||
self._lut_cache[key] = lut
|
||||
return lut
|
||||
|
||||
def get_value(self) -> float:
|
||||
from ledgrab.core.processing.daylight_settings import get_daylight_timezone
|
||||
from ledgrab.core.processing.daylight_stream import _now_in_tz, _utc_offset_hours_for
|
||||
|
||||
tz_name = get_daylight_timezone()
|
||||
if self._use_real_time:
|
||||
now = datetime.now()
|
||||
now = _now_in_tz(tz_name)
|
||||
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
|
||||
lut = self._resolve_lut(now.timetuple().tm_yday, _utc_offset_hours_for(tz_name, now))
|
||||
else:
|
||||
t_elapsed = time.perf_counter() - self._start_time
|
||||
cycle_seconds = 240.0 / max(self._speed, 0.01)
|
||||
phase = (t_elapsed % cycle_seconds) / cycle_seconds
|
||||
minute_of_day = phase * 1440.0
|
||||
lut = self._default_lut
|
||||
|
||||
idx = int(minute_of_day) % 1440
|
||||
r, g, b = self._lut[idx]
|
||||
r, g, b = lut[idx]
|
||||
|
||||
# BT.601 luminance → 0..1
|
||||
luminance = (0.299 * float(r) + 0.587 * float(g) + 0.114 * float(b)) / 255.0
|
||||
@@ -637,8 +668,10 @@ class DaylightValueStream(ValueStream):
|
||||
self._speed = source.speed
|
||||
self._use_real_time = source.use_real_time
|
||||
self._latitude = source.latitude
|
||||
self._longitude = source.longitude
|
||||
self._min = source.min_value
|
||||
self._max = source.max_value
|
||||
self._lut_cache.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -669,10 +702,34 @@ class StaticColorValueStream(ValueStream):
|
||||
)
|
||||
|
||||
|
||||
class AnimatedColorValueStream(ValueStream):
|
||||
"""Cycles through a list of colors over time."""
|
||||
def _ease_color_frac(t: float, easing: str) -> float:
|
||||
"""Remap a 0..1 segment fraction through a named easing curve.
|
||||
|
||||
def __init__(self, colors, speed=10.0, easing="linear"):
|
||||
Unknown names fall back to linear so older configs and forward-compat
|
||||
payloads keep working.
|
||||
"""
|
||||
if easing == "ease_in":
|
||||
return t * t * t
|
||||
if easing == "ease_out":
|
||||
u = 1.0 - t
|
||||
return 1.0 - u * u * u
|
||||
if easing == "ease_in_out":
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
if easing == "sine":
|
||||
return 0.5 - 0.5 * math.cos(math.pi * t)
|
||||
return t
|
||||
|
||||
|
||||
class AnimatedColorValueStream(ValueStream):
|
||||
"""Cycles through a list of colors over time.
|
||||
|
||||
When a ``clock`` runtime is provided, animation is driven by the
|
||||
clock's pause-aware elapsed time and speed multiplier so multiple
|
||||
streams sharing the same clock stay in lockstep. When no clock is
|
||||
set, falls back to wall-clock time scaled by ``speed`` (cycles/min).
|
||||
"""
|
||||
|
||||
def __init__(self, colors, speed=10.0, easing="linear", clock=None):
|
||||
self._colors = [
|
||||
(int(c[0]), int(c[1]), int(c[2]))
|
||||
for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
|
||||
@@ -681,24 +738,47 @@ class AnimatedColorValueStream(ValueStream):
|
||||
self._speed = max(0.01, float(speed))
|
||||
self._easing = easing
|
||||
self._start_time = 0.0
|
||||
self._clock = clock
|
||||
# Last frame state — held while the clock is paused so get_color()
|
||||
# returns a stable color instead of jumping.
|
||||
self._last_phase = 0.0
|
||||
|
||||
def start(self) -> None:
|
||||
self._start_time = time.monotonic()
|
||||
|
||||
def set_clock(self, clock) -> None:
|
||||
"""Set or clear the sync clock runtime. Thread-safe (atomic ref swap)."""
|
||||
self._clock = clock
|
||||
|
||||
def get_value(self) -> float:
|
||||
r, g, b = self.get_color()
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
||||
|
||||
def get_color(self) -> tuple:
|
||||
elapsed = time.monotonic() - self._start_time
|
||||
cycle_time = 60.0 / self._speed
|
||||
clock = self._clock
|
||||
n = len(self._colors)
|
||||
if clock is not None:
|
||||
# Clock provides real elapsed seconds (pause-aware) and a speed
|
||||
# multiplier. We treat self._speed as the base cpm and apply the
|
||||
# clock's speed on top, matching the convention used by CSS
|
||||
# animation streams.
|
||||
cycle_time = 60.0 / max(0.01, self._speed * float(clock.speed))
|
||||
if not clock.is_running:
|
||||
phase = self._last_phase
|
||||
else:
|
||||
elapsed = clock.get_time()
|
||||
phase = (elapsed / cycle_time * n) % n
|
||||
self._last_phase = phase
|
||||
else:
|
||||
elapsed = time.monotonic() - self._start_time
|
||||
cycle_time = 60.0 / self._speed
|
||||
phase = (elapsed / cycle_time * n) % n
|
||||
self._last_phase = phase
|
||||
|
||||
if self._easing == "step":
|
||||
idx = int((elapsed / cycle_time * n) % n)
|
||||
return self._colors[idx]
|
||||
phase = (elapsed / cycle_time * n) % n
|
||||
return self._colors[int(phase) % n]
|
||||
idx = int(phase)
|
||||
frac = phase - idx
|
||||
frac = _ease_color_frac(phase - idx, self._easing)
|
||||
c1 = self._colors[idx % n]
|
||||
c2 = self._colors[(idx + 1) % n]
|
||||
return (
|
||||
@@ -1466,6 +1546,7 @@ class ValueStreamManager:
|
||||
gradient_store: Optional[Any] = None,
|
||||
event_bus: Optional["GameEventBus"] = None,
|
||||
audio_processing_template_store=None,
|
||||
sync_clock_manager: Optional["SyncClockManager"] = None,
|
||||
):
|
||||
self._value_source_store = value_source_store
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
@@ -1477,8 +1558,12 @@ class ValueStreamManager:
|
||||
self._gradient_store = gradient_store
|
||||
self._event_bus = event_bus
|
||||
self._audio_processing_template_store = audio_processing_template_store
|
||||
self._sync_clock_manager = sync_clock_manager
|
||||
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
||||
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
||||
# Tracks which clock_id (if any) was acquired for each stream so we
|
||||
# can release/swap it without re-querying the store at teardown time.
|
||||
self._stream_clock_ids: Dict[str, str] = {} # vs_id → clock_id
|
||||
|
||||
def acquire(self, vs_id: str) -> ValueStream:
|
||||
"""Get or create a shared ValueStream for the given ValueSource.
|
||||
@@ -1492,7 +1577,7 @@ class ValueStreamManager:
|
||||
return self._streams[vs_id]
|
||||
|
||||
source = self._value_source_store.get_source(vs_id)
|
||||
stream = self._create_stream(source)
|
||||
stream = self._create_stream(source, vs_id)
|
||||
stream.start()
|
||||
self._streams[vs_id] = stream
|
||||
self._ref_counts[vs_id] = 1
|
||||
@@ -1512,6 +1597,7 @@ class ValueStreamManager:
|
||||
if stream:
|
||||
stream.stop()
|
||||
del self._ref_counts[vs_id]
|
||||
self._release_clock_for(vs_id)
|
||||
logger.info(f"Released value stream {vs_id} (last ref)")
|
||||
else:
|
||||
logger.info(f"Released ref for value stream {vs_id} (refs={refs})")
|
||||
@@ -1527,8 +1613,53 @@ class ValueStreamManager:
|
||||
stream = self._streams.get(vs_id)
|
||||
if stream:
|
||||
stream.update_source(source)
|
||||
self._sync_clock_binding(vs_id, source, stream)
|
||||
logger.debug(f"Updated value stream {vs_id}")
|
||||
|
||||
def _sync_clock_binding(self, vs_id: str, source: "ValueSource", stream: ValueStream) -> None:
|
||||
"""Hot-swap the sync-clock runtime attached to *stream* if needed."""
|
||||
if not self._sync_clock_manager or not hasattr(stream, "set_clock"):
|
||||
return
|
||||
new_clock_id = getattr(source, "clock_id", None) or None
|
||||
old_clock_id = self._stream_clock_ids.get(vs_id)
|
||||
if new_clock_id == old_clock_id:
|
||||
return
|
||||
new_runtime = None
|
||||
if new_clock_id:
|
||||
try:
|
||||
new_runtime = self._sync_clock_manager.acquire(new_clock_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not acquire sync clock %s for value stream %s: %s",
|
||||
new_clock_id,
|
||||
vs_id,
|
||||
e,
|
||||
)
|
||||
new_runtime = None
|
||||
new_clock_id = None
|
||||
try:
|
||||
stream.set_clock(new_runtime)
|
||||
except Exception as e:
|
||||
logger.warning("set_clock failed on value stream %s: %s", vs_id, e)
|
||||
if new_clock_id:
|
||||
self._stream_clock_ids[vs_id] = new_clock_id
|
||||
else:
|
||||
self._stream_clock_ids.pop(vs_id, None)
|
||||
if old_clock_id:
|
||||
try:
|
||||
self._sync_clock_manager.release(old_clock_id)
|
||||
except Exception as e:
|
||||
logger.debug("Sync clock release for %s: %s", old_clock_id, e)
|
||||
|
||||
def _release_clock_for(self, vs_id: str) -> None:
|
||||
"""Release the sync clock acquired for *vs_id* (if any)."""
|
||||
clock_id = self._stream_clock_ids.pop(vs_id, None)
|
||||
if clock_id and self._sync_clock_manager:
|
||||
try:
|
||||
self._sync_clock_manager.release(clock_id)
|
||||
except Exception as e:
|
||||
logger.debug("Sync clock release for %s: %s", clock_id, e)
|
||||
|
||||
def refresh_audio_filter_pipelines(self, template_id: str) -> None:
|
||||
"""Rebuild audio filter pipelines for any running AudioValueStream
|
||||
that references the given audio processing template ID.
|
||||
@@ -1555,11 +1686,19 @@ class ValueStreamManager:
|
||||
stream.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping value stream {vs_id}: {e}")
|
||||
# Release any sync clocks held by streams.
|
||||
if self._sync_clock_manager:
|
||||
for vs_id, clock_id in self._stream_clock_ids.items():
|
||||
try:
|
||||
self._sync_clock_manager.release(clock_id)
|
||||
except Exception as e:
|
||||
logger.debug("Sync clock release for %s during shutdown: %s", clock_id, e)
|
||||
self._stream_clock_ids.clear()
|
||||
self._streams.clear()
|
||||
self._ref_counts.clear()
|
||||
logger.info("Released all value streams")
|
||||
|
||||
def _create_stream(self, source: "ValueSource") -> ValueStream:
|
||||
def _create_stream(self, source: "ValueSource", vs_id: Optional[str] = None) -> ValueStream:
|
||||
"""Factory: create the appropriate ValueStream for a ValueSource."""
|
||||
from ledgrab.storage.value_source import (
|
||||
AdaptiveValueSource,
|
||||
@@ -1608,6 +1747,7 @@ class ValueStreamManager:
|
||||
speed=source.speed,
|
||||
use_real_time=source.use_real_time,
|
||||
latitude=source.latitude,
|
||||
longitude=source.longitude,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
@@ -1634,10 +1774,24 @@ class ValueStreamManager:
|
||||
return StaticColorValueStream(color=source.color)
|
||||
|
||||
if isinstance(source, AnimatedColorValueSource):
|
||||
clock_runtime = None
|
||||
if source.clock_id and self._sync_clock_manager:
|
||||
try:
|
||||
clock_runtime = self._sync_clock_manager.acquire(source.clock_id)
|
||||
if vs_id is not None:
|
||||
self._stream_clock_ids[vs_id] = source.clock_id
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not acquire sync clock %s for value source %s: %s",
|
||||
source.clock_id,
|
||||
source.id,
|
||||
e,
|
||||
)
|
||||
return AnimatedColorValueStream(
|
||||
colors=source.colors,
|
||||
speed=source.speed,
|
||||
easing=source.easing,
|
||||
clock=clock_runtime,
|
||||
)
|
||||
|
||||
if isinstance(source, AdaptiveTimeColorValueSource):
|
||||
|
||||
@@ -82,10 +82,17 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._resolved_display_index: Optional[int] = None
|
||||
self._device_config = None # populated on start(), typed DeviceConfig
|
||||
|
||||
# Fit-to-device linspace cache (per-instance to avoid cross-target thrash)
|
||||
# Fit-to-device cache (per-instance to avoid cross-target thrash).
|
||||
# Holds precomputed floor/ceil source indices, fractional weights,
|
||||
# and reusable scratch buffers so the per-frame interpolation runs
|
||||
# entirely with in-place numpy ops — no allocations.
|
||||
self._fit_cache_key: tuple = (0, 0)
|
||||
self._fit_cache_src: Optional[np.ndarray] = None
|
||||
self._fit_cache_dst: Optional[np.ndarray] = None
|
||||
self._fit_floor_idx: Optional[np.ndarray] = None
|
||||
self._fit_ceil_idx: Optional[np.ndarray] = None
|
||||
self._fit_frac: Optional[np.ndarray] = None
|
||||
self._fit_left_u8: Optional[np.ndarray] = None
|
||||
self._fit_right_u8: Optional[np.ndarray] = None
|
||||
self._fit_blend_f32: Optional[np.ndarray] = None
|
||||
self._fit_result_buf: Optional[np.ndarray] = None
|
||||
|
||||
# LED preview WebSocket clients
|
||||
@@ -384,6 +391,69 @@ class WledTargetProcessor(TargetProcessor):
|
||||
logger.debug("Device probe failed for %s: %s", device_url, e)
|
||||
return False
|
||||
|
||||
async def _run_liveness_probe_loop(self, device_url: str, probe_interval: float = 10.0) -> None:
|
||||
"""Background loop that probes the device and updates adaptive state.
|
||||
|
||||
Runs independently from the per-frame processing loop so the hot
|
||||
path doesn't pay for `_probe_task.done()` / scheduling checks every
|
||||
iteration. Updates ``self._device_reachable``,
|
||||
``self._metrics.device_streaming_reachable`` and (when adaptive FPS
|
||||
is enabled) ``self._effective_fps`` directly.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(2.0)) as client:
|
||||
while self._is_running:
|
||||
try:
|
||||
reachable = await self._probe_device(device_url, client)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
reachable = False
|
||||
|
||||
prev_reachable = self._device_reachable
|
||||
self._device_reachable = reachable
|
||||
self._metrics.device_streaming_reachable = reachable
|
||||
|
||||
if self._adaptive_fps:
|
||||
target_fps = self._target_fps if self._target_fps > 0 else 30
|
||||
if not reachable:
|
||||
old_eff = self._effective_fps
|
||||
new_eff = max(1, self._effective_fps // 2)
|
||||
if old_eff != new_eff:
|
||||
self._effective_fps = new_eff
|
||||
logger.warning(
|
||||
"[ADAPTIVE] %s device unreachable, FPS %d → %d",
|
||||
self._target_id,
|
||||
old_eff,
|
||||
new_eff,
|
||||
)
|
||||
elif self._effective_fps < target_fps:
|
||||
step = max(1, target_fps // 8)
|
||||
old_eff = self._effective_fps
|
||||
new_eff = min(target_fps, self._effective_fps + step)
|
||||
if old_eff != new_eff:
|
||||
self._effective_fps = new_eff
|
||||
logger.info(
|
||||
"[ADAPTIVE] %s device reachable, FPS %d → %d",
|
||||
self._target_id,
|
||||
old_eff,
|
||||
new_eff,
|
||||
)
|
||||
|
||||
if prev_reachable != reachable:
|
||||
logger.info(
|
||||
"[PROBE] %s device %s",
|
||||
self._target_id,
|
||||
"reachable" if reachable else "UNREACHABLE",
|
||||
)
|
||||
|
||||
# Cooperative sleep that promptly notices stop().
|
||||
# Sleep in 0.5s chunks so cancellation latency stays < 0.5s.
|
||||
slept = 0.0
|
||||
while slept < probe_interval and self._is_running:
|
||||
chunk = min(0.5, probe_interval - slept)
|
||||
await asyncio.sleep(chunk)
|
||||
slept += chunk
|
||||
|
||||
def get_display_index(self) -> Optional[int]:
|
||||
"""Display index being captured, from the active stream."""
|
||||
if self._resolved_display_index is not None:
|
||||
@@ -399,8 +469,14 @@ class WledTargetProcessor(TargetProcessor):
|
||||
fps_target = self._target_fps
|
||||
|
||||
css_timing: dict = {}
|
||||
css_capture_fps: Optional[int] = None
|
||||
css_capture_fps_actual: Optional[float] = None
|
||||
if self._is_running and self._css_stream is not None:
|
||||
css_timing = self._css_stream.get_last_timing()
|
||||
css_capture_fps = getattr(self._css_stream, "target_fps", None)
|
||||
# `actual_fps` is None for synthetic streams (gradient/static/...)
|
||||
# — only picture/audio/api-input style streams measure it.
|
||||
css_capture_fps_actual = getattr(self._css_stream, "actual_fps", None)
|
||||
|
||||
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
|
||||
# Picture source timing
|
||||
@@ -444,6 +520,9 @@ class WledTargetProcessor(TargetProcessor):
|
||||
"fps_actual": metrics.fps_actual if self._is_running else None,
|
||||
"fps_potential": metrics.fps_potential if self._is_running else None,
|
||||
"fps_target": fps_target,
|
||||
"fps_capture": css_capture_fps,
|
||||
"fps_capture_actual": css_capture_fps_actual,
|
||||
"bytes_sent": metrics.bytes_sent if self._is_running else None,
|
||||
"frames_skipped": metrics.frames_skipped if self._is_running else None,
|
||||
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
|
||||
"fps_current": metrics.fps_current if self._is_running else None,
|
||||
@@ -637,24 +716,57 @@ class WledTargetProcessor(TargetProcessor):
|
||||
# ----- Private: processing loop -----
|
||||
|
||||
def _fit_to_device(self, colors: np.ndarray, device_led_count: int) -> np.ndarray:
|
||||
"""Resample colors to match the target LED count."""
|
||||
"""Resample colors to match the target LED count.
|
||||
|
||||
Linear interpolation using floor/ceil source indices and fractional
|
||||
weights — all precomputed when ``(n, device_led_count)`` changes.
|
||||
Per-frame work is two ``np.take`` calls and a few in-place ops on
|
||||
pre-allocated scratch buffers. No per-frame allocations.
|
||||
"""
|
||||
n = len(colors)
|
||||
if n == device_led_count or device_led_count <= 0:
|
||||
return colors
|
||||
|
||||
key = (n, device_led_count)
|
||||
if self._fit_cache_key != key:
|
||||
self._fit_cache_src = np.linspace(0, 1, n)
|
||||
self._fit_cache_dst = np.linspace(0, 1, device_led_count)
|
||||
self._fit_cache_key = key
|
||||
if device_led_count > 1 and n > 1:
|
||||
t = np.arange(device_led_count, dtype=np.float64) * (
|
||||
(n - 1) / (device_led_count - 1)
|
||||
)
|
||||
else:
|
||||
t = np.zeros(device_led_count, dtype=np.float64)
|
||||
floor_idx = np.floor(t).astype(np.int64)
|
||||
np.clip(floor_idx, 0, n - 1, out=floor_idx)
|
||||
ceil_idx = np.minimum(floor_idx + 1, n - 1)
|
||||
frac = (t - floor_idx).astype(np.float32)[:, None] # (M, 1) for channel broadcast
|
||||
self._fit_floor_idx = floor_idx
|
||||
self._fit_ceil_idx = ceil_idx
|
||||
self._fit_frac = frac
|
||||
self._fit_left_u8 = np.empty((device_led_count, 3), dtype=np.uint8)
|
||||
self._fit_right_u8 = np.empty((device_led_count, 3), dtype=np.uint8)
|
||||
self._fit_blend_f32 = np.empty((device_led_count, 3), dtype=np.float32)
|
||||
self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
|
||||
buf = self._fit_result_buf
|
||||
for ch in range(min(colors.shape[1], 3)):
|
||||
np.copyto(
|
||||
buf[:, ch],
|
||||
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]),
|
||||
casting="unsafe",
|
||||
)
|
||||
return buf
|
||||
self._fit_cache_key = key
|
||||
|
||||
# Source slice: ColorStripStreams produce (N, 3); guard against (N, 4) RGBA.
|
||||
rgb = colors[:, :3] if colors.ndim == 2 and colors.shape[1] > 3 else colors
|
||||
|
||||
left_u8 = self._fit_left_u8
|
||||
right_u8 = self._fit_right_u8
|
||||
blend = self._fit_blend_f32
|
||||
out = self._fit_result_buf
|
||||
|
||||
# uint8 → uint8 take with `out=` — no allocation
|
||||
np.take(rgb, self._fit_floor_idx, axis=0, out=left_u8)
|
||||
np.take(rgb, self._fit_ceil_idx, axis=0, out=right_u8)
|
||||
# Promote right to float32 in pre-allocated scratch
|
||||
np.copyto(blend, right_u8, casting="unsafe") # blend = right (float32)
|
||||
blend -= left_u8 # blend = right - left
|
||||
blend *= self._fit_frac # blend = frac * (right - left)
|
||||
blend += left_u8 # blend = left + frac * (right - left)
|
||||
np.clip(blend, 0, 255, out=blend)
|
||||
np.copyto(out, blend, casting="unsafe") # float32 → uint8
|
||||
return out
|
||||
|
||||
async def _send_to_device(self, send_colors: np.ndarray) -> float:
|
||||
"""Send colors to LED device and return send time in ms."""
|
||||
@@ -663,6 +775,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._led_client.send_pixels_fast(send_colors)
|
||||
else:
|
||||
await self._led_client.send_pixels(send_colors)
|
||||
# Approximate network throughput counter (LED-payload bytes only).
|
||||
self._metrics.bytes_sent += int(send_colors.nbytes)
|
||||
return (time.perf_counter() - t_start) * 1000
|
||||
|
||||
@staticmethod
|
||||
@@ -774,14 +888,16 @@ class WledTargetProcessor(TargetProcessor):
|
||||
_diag_slow_iters: collections.deque = collections.deque(maxlen=50)
|
||||
_diag_iter_times: collections.deque = collections.deque(maxlen=300)
|
||||
# --- Liveness probe + adaptive FPS ---
|
||||
# The probe runs as an independent task so the hot loop doesn't
|
||||
# pay for per-iteration probe-state checks.
|
||||
_device_url = self._device_config.device_url if self._device_config else ""
|
||||
_probe_enabled = _device_url.startswith("http")
|
||||
_probe_interval = 10.0 # seconds between probes
|
||||
_last_probe_time = 0.0 # force first probe soon (after 10s)
|
||||
_probe_task: Optional[asyncio.Task] = None
|
||||
_probe_client: Optional[httpx.AsyncClient] = None
|
||||
if _probe_enabled:
|
||||
_probe_client = httpx.AsyncClient(timeout=httpx.Timeout(2.0))
|
||||
_probe_task = asyncio.create_task(
|
||||
self._run_liveness_probe_loop(_device_url),
|
||||
name=f"liveness-probe-{self._target_id}",
|
||||
)
|
||||
self._effective_fps = self._target_fps
|
||||
self._device_reachable = None
|
||||
|
||||
@@ -805,63 +921,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
loop_start = now = time.perf_counter()
|
||||
target_fps = self._target_fps if self._target_fps > 0 else 30
|
||||
|
||||
# --- Liveness probe ---
|
||||
# Collect result as soon as it's done (every iteration)
|
||||
if _probe_task is not None and _probe_task.done():
|
||||
try:
|
||||
reachable = _probe_task.result()
|
||||
except Exception:
|
||||
reachable = False
|
||||
prev_reachable = self._device_reachable
|
||||
self._device_reachable = reachable
|
||||
self._metrics.device_streaming_reachable = reachable
|
||||
_probe_task = None
|
||||
|
||||
if self._adaptive_fps:
|
||||
if not reachable:
|
||||
# Backoff: halve effective FPS
|
||||
old_eff = self._effective_fps
|
||||
self._effective_fps = max(1, self._effective_fps // 2)
|
||||
if old_eff != self._effective_fps:
|
||||
logger.warning(
|
||||
f"[ADAPTIVE] {self._target_id} device unreachable, "
|
||||
f"FPS {old_eff} → {self._effective_fps}"
|
||||
)
|
||||
next_frame_time = time.perf_counter()
|
||||
else:
|
||||
# Recovery: gradually increase
|
||||
if self._effective_fps < target_fps:
|
||||
step = max(1, target_fps // 8)
|
||||
old_eff = self._effective_fps
|
||||
self._effective_fps = min(
|
||||
target_fps, self._effective_fps + step
|
||||
)
|
||||
if old_eff != self._effective_fps:
|
||||
logger.info(
|
||||
f"[ADAPTIVE] {self._target_id} device reachable, "
|
||||
f"FPS {old_eff} → {self._effective_fps}"
|
||||
)
|
||||
next_frame_time = time.perf_counter()
|
||||
|
||||
if prev_reachable != reachable:
|
||||
logger.info(
|
||||
f"[PROBE] {self._target_id} device "
|
||||
f"{'reachable' if reachable else 'UNREACHABLE'}"
|
||||
)
|
||||
|
||||
# Fire new probe every _probe_interval seconds
|
||||
if (
|
||||
_probe_enabled
|
||||
and _probe_task is None
|
||||
and (now - _last_probe_time) >= _probe_interval
|
||||
):
|
||||
if _probe_client is not None:
|
||||
_last_probe_time = now
|
||||
_probe_task = asyncio.create_task(
|
||||
self._probe_device(_device_url, _probe_client)
|
||||
)
|
||||
|
||||
# Use effective FPS for frame timing
|
||||
# Use effective FPS for frame timing. ``self._effective_fps``
|
||||
# is mutated by the liveness probe task — read once.
|
||||
effective_fps = self._effective_fps if self._adaptive_fps else target_fps
|
||||
self._metrics.fps_effective = effective_fps
|
||||
frame_time = 1.0 / effective_fps
|
||||
@@ -981,8 +1042,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
await self._broadcast_led_preview(send_colors, cur_brightness)
|
||||
_last_preview_broadcast = now
|
||||
self._metrics.frames_skipped += 1
|
||||
self._metrics.fps_current = _fps_current_from_timestamps()
|
||||
await asyncio.sleep(SKIP_REPOLL)
|
||||
self._metrics.fps_current = _fps_current_from_timestamps()
|
||||
continue
|
||||
|
||||
# Force-send preview when a new client just connected
|
||||
@@ -1024,10 +1085,10 @@ class WledTargetProcessor(TargetProcessor):
|
||||
await self._broadcast_led_preview(send_colors, cur_brightness)
|
||||
_last_preview_broadcast = now
|
||||
self._metrics.frames_skipped += 1
|
||||
self._metrics.fps_current = _fps_current_from_timestamps()
|
||||
is_animated = stream.is_animated
|
||||
repoll = SKIP_REPOLL if is_animated else frame_time
|
||||
await asyncio.sleep(repoll)
|
||||
self._metrics.fps_current = _fps_current_from_timestamps()
|
||||
continue
|
||||
|
||||
prev_frame_ref = frame
|
||||
@@ -1150,9 +1211,9 @@ class WledTargetProcessor(TargetProcessor):
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Clean up probe client
|
||||
if _probe_client is not None:
|
||||
await _probe_client.aclose()
|
||||
# Stop the liveness probe task. ``_run_liveness_probe_loop``
|
||||
# owns its own httpx.AsyncClient via ``async with`` so cancelling
|
||||
# the task closes the client cleanly.
|
||||
if _probe_task is not None and not _probe_task.done():
|
||||
_probe_task.cancel()
|
||||
try:
|
||||
|
||||
@@ -588,6 +588,14 @@ class UpdateService:
|
||||
"body": rel.body,
|
||||
"prerelease": rel.prerelease,
|
||||
"published_at": rel.published_at,
|
||||
"assets": [
|
||||
{
|
||||
"name": a.name,
|
||||
"size": a.size,
|
||||
"download_url": a.download_url,
|
||||
}
|
||||
for a in rel.assets
|
||||
],
|
||||
}
|
||||
if rel
|
||||
else None
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Minecraft community adapter
|
||||
# Requires a server-side mod that sends game state via webhook
|
||||
# (e.g., GameStateIntegration mod or custom Fabric/Forge mod)
|
||||
#
|
||||
# Configure your mod to POST JSON to:
|
||||
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
|
||||
|
||||
name: minecraft
|
||||
game: Minecraft
|
||||
protocol: webhook
|
||||
|
||||
mappings:
|
||||
- source_path: player.health
|
||||
event: health
|
||||
min: 0
|
||||
max: 20
|
||||
trigger: on_change
|
||||
|
||||
- source_path: player.armor
|
||||
event: armor
|
||||
min: 0
|
||||
max: 20
|
||||
trigger: on_change
|
||||
|
||||
- source_path: player.food_level
|
||||
event: energy
|
||||
min: 0
|
||||
max: 20
|
||||
trigger: on_change
|
||||
|
||||
- source_path: player.experience_level
|
||||
event: speed
|
||||
min: 0
|
||||
max: 100
|
||||
trigger: on_change
|
||||
|
||||
- source_path: player.deaths
|
||||
event: death
|
||||
trigger: on_increase
|
||||
min: 0
|
||||
max: 100
|
||||
|
||||
- source_path: stats.kills
|
||||
event: kill
|
||||
trigger: on_increase
|
||||
min: 0
|
||||
max: 100
|
||||
|
||||
auth:
|
||||
type: header
|
||||
header: X-Minecraft-Auth
|
||||
|
||||
setup_instructions: |
|
||||
## Minecraft Integration Setup
|
||||
|
||||
This adapter requires a server-side mod that sends game state data as JSON.
|
||||
|
||||
**Recommended mods:**
|
||||
- [GameStateIntegration](https://github.com/example/gsi-mod) (Fabric)
|
||||
- Custom Forge mod using `PlayerTickEvent`
|
||||
|
||||
**Expected JSON format:**
|
||||
```json
|
||||
{
|
||||
"player": {
|
||||
"health": 20.0,
|
||||
"armor": 10,
|
||||
"food_level": 18,
|
||||
"experience_level": 30
|
||||
},
|
||||
"stats": {
|
||||
"kills": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Configure the mod to POST to the event endpoint with the auth token
|
||||
in the `X-Minecraft-Auth` header.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Rocket League community adapter
|
||||
# Uses the SOS (Rocket League Overlay System) plugin
|
||||
# https://gitlab.com/bakkesplugins/sos/sos-plugin
|
||||
#
|
||||
# SOS sends game state via WebSocket, but you can use a bridge
|
||||
# to forward events as HTTP POST to:
|
||||
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
|
||||
|
||||
name: rocket_league
|
||||
game: Rocket League
|
||||
protocol: webhook
|
||||
|
||||
mappings:
|
||||
- source_path: player.boost
|
||||
event: energy
|
||||
min: 0
|
||||
max: 100
|
||||
trigger: on_change
|
||||
|
||||
- source_path: player.speed
|
||||
event: speed
|
||||
min: 0
|
||||
max: 2300
|
||||
trigger: on_value
|
||||
|
||||
- source_path: match.goals_scored
|
||||
event: kill
|
||||
trigger: on_increase
|
||||
min: 0
|
||||
max: 20
|
||||
|
||||
- source_path: match.goals_conceded
|
||||
event: death
|
||||
trigger: on_increase
|
||||
min: 0
|
||||
max: 20
|
||||
|
||||
- source_path: match.time_remaining
|
||||
event: objective_progress
|
||||
min: 0
|
||||
max: 300
|
||||
trigger: on_value
|
||||
|
||||
- source_path: game.started
|
||||
event: match_start
|
||||
trigger: on_change
|
||||
min: 0
|
||||
max: 1
|
||||
|
||||
- source_path: game.ended
|
||||
event: match_end
|
||||
trigger: on_change
|
||||
min: 0
|
||||
max: 1
|
||||
|
||||
- source_path: team.score_blue
|
||||
event: team_a
|
||||
min: 0
|
||||
max: 10
|
||||
trigger: on_change
|
||||
|
||||
- source_path: team.score_orange
|
||||
event: team_b
|
||||
min: 0
|
||||
max: 10
|
||||
trigger: on_change
|
||||
|
||||
setup_instructions: |
|
||||
## Rocket League Integration Setup
|
||||
|
||||
This adapter works with the SOS (Rocket League Overlay System) plugin.
|
||||
|
||||
**Setup:**
|
||||
1. Install BakkesMod: https://bakkesmod.com
|
||||
2. Install the SOS plugin from the BakkesMod plugin manager
|
||||
3. Use a WebSocket-to-HTTP bridge to forward SOS events
|
||||
|
||||
**Bridge tool:**
|
||||
A small script that connects to SOS WebSocket (ws://localhost:49122)
|
||||
and forwards events as HTTP POST to the WLED event endpoint.
|
||||
|
||||
**Expected JSON format:**
|
||||
```json
|
||||
{
|
||||
"player": {
|
||||
"boost": 75,
|
||||
"speed": 1500
|
||||
},
|
||||
"match": {
|
||||
"goals_scored": 2,
|
||||
"goals_conceded": 1,
|
||||
"time_remaining": 180
|
||||
},
|
||||
"team": {
|
||||
"score_blue": 2,
|
||||
"score_orange": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# Valorant community adapter
|
||||
# Uses Overwolf/Insights API or third-party overlay tool
|
||||
# that exposes game state via webhook
|
||||
#
|
||||
# Configure your overlay to POST JSON to:
|
||||
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
|
||||
|
||||
name: valorant
|
||||
game: Valorant
|
||||
protocol: webhook
|
||||
|
||||
mappings:
|
||||
- source_path: player.health
|
||||
event: health
|
||||
min: 0
|
||||
max: 100
|
||||
trigger: on_change
|
||||
|
||||
- source_path: player.shield
|
||||
event: shield
|
||||
min: 0
|
||||
max: 50
|
||||
trigger: on_change
|
||||
|
||||
- source_path: player.money
|
||||
event: gold
|
||||
min: 0
|
||||
max: 9000
|
||||
trigger: on_change
|
||||
|
||||
- source_path: match.kills
|
||||
event: kill
|
||||
trigger: on_increase
|
||||
min: 0
|
||||
max: 50
|
||||
|
||||
- source_path: match.deaths
|
||||
event: death
|
||||
trigger: on_increase
|
||||
min: 0
|
||||
max: 50
|
||||
|
||||
- source_path: match.round_phase
|
||||
event: round_start
|
||||
trigger: on_change
|
||||
min: 0
|
||||
max: 1
|
||||
|
||||
- source_path: match.spike_planted
|
||||
event: objective_captured
|
||||
trigger: on_change
|
||||
min: 0
|
||||
max: 1
|
||||
|
||||
auth:
|
||||
type: header
|
||||
header: X-Valorant-Auth
|
||||
|
||||
setup_instructions: |
|
||||
## Valorant Integration Setup
|
||||
|
||||
Valorant does not have a native Game State Integration API.
|
||||
You need a third-party tool to capture and forward game data.
|
||||
|
||||
**Options:**
|
||||
- Overwolf with a game events plugin
|
||||
- Insights.gg capture API
|
||||
- Custom screen-reading overlay
|
||||
|
||||
**Expected JSON format:**
|
||||
```json
|
||||
{
|
||||
"player": {
|
||||
"health": 100,
|
||||
"shield": 50,
|
||||
"money": 3900
|
||||
},
|
||||
"match": {
|
||||
"kills": 12,
|
||||
"deaths": 5,
|
||||
"round_phase": 1,
|
||||
"spike_planted": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,15 +425,35 @@ 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()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping OS notification listener: {e}")
|
||||
|
||||
# Stop all processing
|
||||
# Stop all processing.
|
||||
# The shutdown action setting controls whether per-device restore
|
||||
# frames are sent: "stop_targets" (default) runs the normal stop
|
||||
# sequence; "nothing" cancels capture tasks so the LEDs freeze on
|
||||
# their last frame.
|
||||
try:
|
||||
await processor_manager.stop_all()
|
||||
from ledgrab.api.routes.system_settings import load_shutdown_action
|
||||
|
||||
action = load_shutdown_action(db)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading shutdown action setting, defaulting to stop_targets: {e}")
|
||||
action = "stop_targets"
|
||||
|
||||
logger.info("Shutdown action: %s", action)
|
||||
try:
|
||||
await processor_manager.stop_all(restore_devices=action != "nothing")
|
||||
logger.info("Stopped all processors")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping processors: {e}")
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
@import './fonts.css';
|
||||
@import './base.css';
|
||||
@import './layout.css';
|
||||
@import './sidebar.css';
|
||||
@import './components.css';
|
||||
@import './cards.css';
|
||||
@import './modal.css';
|
||||
@import './calibration.css';
|
||||
@import './advanced-calibration.css';
|
||||
@import './dashboard.css';
|
||||
@import './dashboard-customize.css';
|
||||
@import './streams.css';
|
||||
@import './patterns.css';
|
||||
@import './automations.css';
|
||||
|
||||
@@ -18,88 +18,95 @@ h1 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive preset grid — matches the mockup's tight 4-up rhythm
|
||||
on desktop and gracefully reflows on narrow viewports. */
|
||||
.ap-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ─── Preset card (shared) ─── */
|
||||
|
||||
.ap-card {
|
||||
--ap-ch: var(--ch-magenta, #ff4ade);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card-bg);
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
padding: 4px 4px 3px;
|
||||
border: 1px solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-md, 8px);
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
cursor: pointer;
|
||||
transition: border-color var(--duration-normal) var(--ease-out),
|
||||
box-shadow var(--duration-normal) var(--ease-out),
|
||||
transform var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
.ap-card.ap-card-bg { --ap-ch: var(--ch-cyan, #00d8ff); }
|
||||
|
||||
.ap-card:hover {
|
||||
border-color: var(--text-muted);
|
||||
border-color: color-mix(in srgb, var(--ap-ch) 50%, var(--lux-line, var(--border-color)));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ap-card.active {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px var(--primary-color),
|
||||
0 0 12px -2px color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
border: 2px solid var(--ap-ch);
|
||||
padding: 3px 3px 2px;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ap-ch) 40%, transparent),
|
||||
0 0 16px -4px color-mix(in srgb, var(--ap-ch) 50%, transparent);
|
||||
}
|
||||
|
||||
.ap-card.active::after {
|
||||
content: '\2713';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
font-size: 0.65rem;
|
||||
top: 3px;
|
||||
right: 4px;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
color: var(--ap-ch);
|
||||
}
|
||||
|
||||
.ap-card-label {
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ap-card.active .ap-card-label {
|
||||
color: var(--primary-color);
|
||||
color: var(--ap-ch);
|
||||
}
|
||||
|
||||
/* ─── Style preset preview ─── */
|
||||
|
||||
.ap-card-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-sm);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--lux-r-sm, 4px);
|
||||
border: 1px solid;
|
||||
padding: 8px 7px 6px;
|
||||
padding: 5px 5px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ap-card-accent {
|
||||
width: 24px;
|
||||
height: 4px;
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.ap-card-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ap-card-lines span {
|
||||
@@ -113,12 +120,12 @@ h1 {
|
||||
|
||||
.ap-bg-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-sm);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--lux-r-sm, 4px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--lux-line, var(--border-color));
|
||||
}
|
||||
|
||||
.ap-bg-preview-inner {
|
||||
|
||||
@@ -1,44 +1,23 @@
|
||||
/* ===== AUTOMATIONS ===== */
|
||||
|
||||
.badge-automation-active {
|
||||
background: var(--success-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge-automation-inactive {
|
||||
background: var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.badge-automation-disabled {
|
||||
background: var(--border-color);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.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 */
|
||||
|
||||
@@ -16,7 +16,25 @@
|
||||
--danger-color: #f44336;
|
||||
--warning-color: #ff9800;
|
||||
--info-color: #2196F3;
|
||||
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
|
||||
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
|
||||
|
||||
/* ── Lumenworks design tokens (additive; active alongside legacy tokens
|
||||
during phased migration). Typography + spatial system for the
|
||||
studio-console redesign. Channel colors defined in the theme
|
||||
blocks below so they can shift with light/dark mode. ──────── */
|
||||
--font-display: 'Big Shoulders Display', 'Orbitron', 'Manrope', sans-serif;
|
||||
--font-brand: 'Orbitron', sans-serif;
|
||||
--font-body: 'Manrope', 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
--lux-r-sm: 3px;
|
||||
--lux-r-md: 6px;
|
||||
--lux-r-lg: 10px;
|
||||
--lux-r-xl: 14px;
|
||||
|
||||
/* Hairline + bold dividers — thinner than the legacy 1px --border-color
|
||||
to get the "silkscreened panel" feel. */
|
||||
--lux-hairline: 1px;
|
||||
--lux-rule: 2px;
|
||||
|
||||
/* Spacing scale */
|
||||
--space-xs: 4px;
|
||||
@@ -81,9 +99,9 @@
|
||||
|
||||
/* Dark theme (default) */
|
||||
[data-theme="dark"] {
|
||||
--bg-color: #1a1a1a;
|
||||
--bg-secondary: #242424;
|
||||
--card-bg: #2d2d2d;
|
||||
--bg-color: #000000;
|
||||
--bg-secondary: #0a0b0d;
|
||||
--card-bg: #000000;
|
||||
--text-color: #e0e0e0;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #999;
|
||||
@@ -96,12 +114,40 @@
|
||||
--hover-bg: rgba(255, 255, 255, 0.05);
|
||||
--input-bg: #1a1a2e;
|
||||
color-scheme: dark;
|
||||
|
||||
/* ── Lumenworks dark palette — page is pure black, cards elevate ── */
|
||||
--lux-bg-0: #000000;
|
||||
--lux-bg-1: #0e1014;
|
||||
--lux-bg-2: #15181d;
|
||||
--lux-bg-3: #1c2027;
|
||||
--lux-line: #232831;
|
||||
--lux-line-bold:#2e3440;
|
||||
--lux-ink: #e6ebf2;
|
||||
--lux-ink-dim: #8b95a5;
|
||||
--lux-ink-mute: #5b6473;
|
||||
--lux-ink-faint:#3a414c;
|
||||
|
||||
/* Channel palette — consistent across tabs for entity types.
|
||||
--ch-signal tracks --primary-color so the accent color picker
|
||||
propagates through the brand mark, running stripes, transport
|
||||
chip, active tabs, etc. Other channels are fixed hues used for
|
||||
non-primary entity types. */
|
||||
--ch-signal: var(--primary-color);
|
||||
--ch-signal-dim: var(--primary-text-color, var(--primary-color));
|
||||
--ch-cyan: #00d8ff; /* data / sources / screen */
|
||||
--ch-magenta: #ff4ade; /* audio / FFT */
|
||||
--ch-amber: #ffb800; /* autostart / pending */
|
||||
--ch-coral: #ff5e5e; /* offline / error / alarm */
|
||||
--ch-violet: #8b7eff; /* graph / scenes / automations */
|
||||
|
||||
--lux-signal-glow: 0 0 14px color-mix(in srgb, var(--ch-signal) 40%, transparent);
|
||||
--lux-shadow-rack: 0 1px 0 rgba(255, 255, 255, 0.03), 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme="light"] {
|
||||
--bg-color: #f5f5f5;
|
||||
--bg-secondary: #eee;
|
||||
--bg-color: #ffffff;
|
||||
--bg-secondary: #fafbfc;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #333333;
|
||||
--text-primary: #333333;
|
||||
@@ -120,6 +166,32 @@
|
||||
--primary-color-on-light-bg: #2e7d32;
|
||||
--primary-text: #2e7d32;
|
||||
color-scheme: light;
|
||||
|
||||
/* ── Lumenworks light palette — page is pure white, cards slightly
|
||||
off-white so the stripe + hairline border still read against
|
||||
the page. WCAG AA tuned. ── */
|
||||
--lux-bg-0: #ffffff;
|
||||
--lux-bg-1: #f6f8fb;
|
||||
--lux-bg-2: #eef1f5;
|
||||
--lux-bg-3: #e4e8ee;
|
||||
--lux-line: #dee3ea;
|
||||
--lux-line-bold:#c4ccd6;
|
||||
--lux-ink: #0f1419;
|
||||
--lux-ink-dim: #4c5866;
|
||||
--lux-ink-mute: #6b7684;
|
||||
--lux-ink-faint:#a5afbc;
|
||||
|
||||
/* --ch-signal tracks --primary-color so the accent picker propagates. */
|
||||
--ch-signal: var(--primary-color);
|
||||
--ch-signal-dim: var(--primary-text-color, var(--primary-color));
|
||||
--ch-cyan: #006b88;
|
||||
--ch-magenta: #b01a99;
|
||||
--ch-amber: #a56a00;
|
||||
--ch-coral: #d8392e;
|
||||
--ch-violet: #5b4fd0;
|
||||
|
||||
--lux-signal-glow: 0 0 12px color-mix(in srgb, var(--ch-signal) 28%, transparent);
|
||||
--lux-shadow-rack: 0 1px 0 rgba(255, 255, 255, 0.6), 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Default to dark theme */
|
||||
@@ -137,10 +209,12 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
font-family: var(--font-body, 'Manrope', 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
line-height: 1.55;
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
html.modal-open {
|
||||
@@ -167,21 +241,11 @@ html.modal-open {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* When bg-anim is active, make entity cards slightly translucent
|
||||
so the shader bleeds through. Only target cards — NOT modals,
|
||||
pickers, tab bars, headers, or other chrome. */
|
||||
[data-bg-anim="on"][data-theme="dark"] .card,
|
||||
[data-bg-anim="on"][data-theme="dark"] .template-card,
|
||||
[data-bg-anim="on"][data-theme="dark"] .add-device-card,
|
||||
[data-bg-anim="on"][data-theme="dark"] .dashboard-target {
|
||||
background: rgba(45, 45, 45, 0.88);
|
||||
}
|
||||
[data-bg-anim="on"][data-theme="light"] .card,
|
||||
[data-bg-anim="on"][data-theme="light"] .template-card,
|
||||
[data-bg-anim="on"][data-theme="light"] .add-device-card,
|
||||
[data-bg-anim="on"][data-theme="light"] .dashboard-target {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
/* Card backgrounds are intentionally stable across the dynamic-bg
|
||||
toggle — the shader bleeds through the page background only.
|
||||
(Previously a translucent override let the shader show through
|
||||
cards too, but it made the same card look different depending on
|
||||
whether the user had the WebGL background enabled.) */
|
||||
/* Blur behind header via pseudo-element — applying backdrop-filter directly
|
||||
to header would create a containing block and break position:fixed on
|
||||
the .tab-bar nested inside it (mobile bottom nav). */
|
||||
|
||||
+1013
-204
File diff suppressed because it is too large
Load Diff
@@ -23,32 +23,40 @@
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-actions .btn-icon {
|
||||
padding: 6px 8px;
|
||||
font-size: 1.1rem;
|
||||
padding: 7px 10px;
|
||||
min-width: 36px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 18px;
|
||||
border: var(--lux-hairline, 1px) solid transparent;
|
||||
border-radius: var(--lux-r-sm, var(--radius-sm));
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
transition: opacity 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease, filter 0.15s ease;
|
||||
flex: 1 1 auto;
|
||||
min-width: 100px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
@@ -62,30 +70,90 @@
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
color: var(--lux-bg-0, var(--primary-contrast));
|
||||
border-color: var(--ch-signal, var(--primary-color));
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
background: var(--ch-coral, var(--danger-color));
|
||||
color: #fff;
|
||||
border-color: var(--ch-coral, var(--danger-color));
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 30%, transparent);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--border-color);
|
||||
color: var(--text-color);
|
||||
background: var(--lux-bg-2, var(--border-color));
|
||||
color: var(--lux-ink-dim, var(--text-color));
|
||||
border-color: var(--lux-line-bold, var(--border-color));
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
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: 8px 12px;
|
||||
font-size: 1.2rem;
|
||||
padding: 7px 10px;
|
||||
font-size: 1rem;
|
||||
flex: 0 0 auto;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
background: transparent;
|
||||
color: var(--lux-ink-dim, var(--text-color));
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
transform: scale(1.1);
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, var(--lux-line-bold, var(--border-color)));
|
||||
filter: none;
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Variant: warning / success for enable/disable action buttons. Keep
|
||||
flat hairline borders; just shift the color + hover glow. */
|
||||
.btn-icon.btn-warning {
|
||||
color: var(--ch-amber, var(--warning-color));
|
||||
border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 35%, transparent);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-icon.btn-warning:hover {
|
||||
background: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 12%, transparent);
|
||||
color: var(--ch-amber, var(--warning-color));
|
||||
border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--ch-amber, var(--warning-color)) 25%, transparent);
|
||||
}
|
||||
|
||||
.btn-icon.btn-success {
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-icon.btn-success:hover {
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent);
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent);
|
||||
}
|
||||
|
||||
.btn-icon:active:not(:disabled) {
|
||||
@@ -161,14 +229,29 @@ input[type="number"],
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease, opacity 0.2s ease;
|
||||
padding: 9px 12px;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-sm, var(--radius-sm));
|
||||
background: var(--lux-bg-0, var(--bg-color));
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Numeric fields use mono for alignment */
|
||||
input[type="number"] {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
input[type="text"]:hover,
|
||||
input[type="url"]:hover,
|
||||
input[type="number"]:hover,
|
||||
input[type="password"]:hover,
|
||||
select:hover {
|
||||
border-color: var(--lux-line-bold, var(--border-color));
|
||||
}
|
||||
|
||||
input[type="number"]:disabled,
|
||||
@@ -190,10 +273,14 @@ input[type="password"] {
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15);
|
||||
border-color: var(--ch-signal, var(--primary-color));
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent),
|
||||
0 0 16px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
|
||||
background: var(--lux-bg-1, var(--bg-color));
|
||||
}
|
||||
|
||||
/* Inline validation states */
|
||||
@@ -360,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 {
|
||||
@@ -384,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 {
|
||||
@@ -414,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;
|
||||
}
|
||||
@@ -675,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);
|
||||
@@ -1033,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 {
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
/* ── Dashboard Customize Panel ──
|
||||
* Slide-in panel on the right edge. Doesn't cover the full viewport so
|
||||
* users see live previews of changes as they toggle settings.
|
||||
*/
|
||||
|
||||
.dash-cust-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
z-index: calc(var(--z-modal, 1000) - 5);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.dash-cust-backdrop.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dash-cust-panel {
|
||||
position: fixed;
|
||||
top: 60px; /* below transport bar */
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(440px, 92vw);
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
border-left: var(--lux-rule, 1px) solid var(--lux-line, var(--border-color));
|
||||
box-shadow: var(--lux-shadow-rack, -8px 0 32px rgba(0, 0, 0, 0.35));
|
||||
z-index: var(--z-modal, 1000);
|
||||
transform: translateX(100%);
|
||||
transition: transform 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dash-cust-panel.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dash-cust-panel { transition: none; }
|
||||
.dash-cust-backdrop { transition: none; }
|
||||
}
|
||||
|
||||
.dash-cust-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dash-cust-header h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display, var(--font-mono, monospace));
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dash-cust-header h2::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -8px;
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
|
||||
}
|
||||
|
||||
.dash-cust-close {
|
||||
background: transparent;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.dash-cust-close:hover {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
border-color: var(--ch-signal, var(--primary-color));
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent);
|
||||
}
|
||||
|
||||
.dash-cust-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 14px 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
/* Prevent scroll chaining: when the panel's scroll reaches its top
|
||||
* or bottom, the wheel/touch scroll should NOT propagate to the
|
||||
* underlying dashboard page. */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Section blocks */
|
||||
.dash-cust-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dash-cust-section + .dash-cust-section {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dash-cust-h3 {
|
||||
margin: 0 0 2px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
}
|
||||
|
||||
.dash-cust-modified {
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--ch-amber, var(--warning-color));
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, transparent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Preset chips */
|
||||
.dash-cust-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dash-cust-chip {
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
padding: 6px 12px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.dash-cust-chip:hover {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
border-color: var(--lux-line-bold, var(--text-secondary));
|
||||
}
|
||||
|
||||
.dash-cust-chip.is-active {
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
border-color: var(--ch-signal, var(--primary-color));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent),
|
||||
0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Rows + lists */
|
||||
.dash-cust-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dash-cust-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: 3px;
|
||||
transition: background 150ms ease, border-color 150ms ease, transform 100ms ease;
|
||||
}
|
||||
|
||||
.dash-cust-row.is-dragging {
|
||||
opacity: 0.55;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.dash-cust-row.is-drop-target {
|
||||
border-color: var(--ch-signal, var(--primary-color));
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent);
|
||||
}
|
||||
|
||||
.dash-cust-row-fixed {
|
||||
background: color-mix(in srgb, var(--lux-line, var(--border-color)) 30%, transparent);
|
||||
}
|
||||
|
||||
.dash-cust-row-drag {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.dash-cust-row-drag:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.dash-cust-row-label {
|
||||
flex: 1 1 auto;
|
||||
font-family: var(--font-body, inherit);
|
||||
font-size: 0.78rem;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-cust-row-label .dash-cust-pin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
}
|
||||
|
||||
.dash-cust-grip {
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dash-cust-row-drag:hover .dash-cust-grip {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
/* Density buttons */
|
||||
.dash-cust-density-group {
|
||||
display: inline-flex;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dash-cust-density {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 2px 6px;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.dash-cust-density:not(:last-child) {
|
||||
border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
}
|
||||
|
||||
.dash-cust-density:hover {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
.dash-cust-density.is-active {
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
/* Eye / toggle button */
|
||||
.dash-cust-eye, .dash-cust-arrow {
|
||||
background: transparent;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
flex: 0 0 26px;
|
||||
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.dash-cust-eye:hover, .dash-cust-arrow:hover {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
border-color: var(--lux-line-bold, var(--text-secondary));
|
||||
}
|
||||
|
||||
.dash-cust-eye.is-on {
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, var(--lux-line, var(--border-color)));
|
||||
}
|
||||
|
||||
.dash-cust-arrow.is-active {
|
||||
color: var(--ch-amber, var(--warning-color));
|
||||
border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, var(--lux-line, var(--border-color)));
|
||||
}
|
||||
|
||||
.dash-cust-arrow {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Segmented controls (global options) */
|
||||
.dash-cust-row .dash-cust-label {
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.dash-cust-seg {
|
||||
display: inline-flex;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.dash-cust-seg-btn {
|
||||
flex: 1 1 auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 5px 8px;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.dash-cust-seg-btn:not(:last-child) {
|
||||
border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
}
|
||||
|
||||
.dash-cust-seg-btn:hover {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
.dash-cust-seg-btn.is-active {
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
/* Mini selects (perf cell options).
|
||||
* The project's components.css applies `select { width: 100%; padding: 9px 12px }`
|
||||
* globally — we override both with higher specificity so the selects size to
|
||||
* their content rather than blowing the row out past the panel edge. */
|
||||
.dash-cust-panel select.dash-cust-mini-select {
|
||||
width: auto;
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.66rem;
|
||||
padding: 3px 18px 3px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dash-cust-panel select.dash-cust-mini-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--ch-signal, var(--primary-color));
|
||||
}
|
||||
|
||||
/* Two-line perf-cell row.
|
||||
* Top line carries the label + reorder + visibility controls so the cell
|
||||
* name is *always* readable. Bottom line carries the per-cell options
|
||||
* (mode / window / scale) labelled with tiny mono captions. */
|
||||
.dash-cust-cell-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.dash-cust-cell-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dash-cust-cell-opts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dash-cust-cell-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash-cust-cell-opt-k {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Help / actions */
|
||||
.dash-cust-help {
|
||||
margin: 0;
|
||||
font-size: 0.65rem;
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dash-cust-actions {
|
||||
border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
padding-top: 14px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dash-cust-actions .btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* Width-mode hooks: applied to dashboard-content, not the panel */
|
||||
#dashboard-content[data-layout-width="centered"] {
|
||||
max-width: 1280px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#dashboard-content[data-layout-width="narrow"] {
|
||||
max-width: 960px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#dashboard-content[data-layout-anim="off"] *,
|
||||
#dashboard-content[data-layout-anim="off"] *::before,
|
||||
#dashboard-content[data-layout-anim="off"] *::after {
|
||||
animation-duration: 0ms !important;
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
#dashboard-content[data-layout-anim="reduced"] *,
|
||||
#dashboard-content[data-layout-anim="reduced"] *::before,
|
||||
#dashboard-content[data-layout-anim="reduced"] *::after {
|
||||
animation-duration: 60ms !important;
|
||||
transition-duration: 80ms !important;
|
||||
}
|
||||
|
||||
/* Density variants per section */
|
||||
.dashboard-section[data-density="compact"] .dashboard-section-content {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-section[data-density="compact"] .dashboard-section-header {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.dashboard-section[data-density="dense"] .dashboard-section-content {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-section[data-density="dense"] .dashboard-section-header {
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.dashboard-section[data-density="dense"] .dashboard-target {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* Mobile collapse */
|
||||
@media (max-width: 720px) {
|
||||
.dash-cust-panel {
|
||||
top: 56px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
/* Local font faces — no external CDN dependency */
|
||||
|
||||
/* DM Sans — latin-ext */
|
||||
/* ── DM Sans (legacy body font — kept during redesign transition) ── */
|
||||
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
@@ -10,7 +11,6 @@
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* DM Sans — latin */
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
@@ -20,7 +20,8 @@
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Orbitron 700 — latin */
|
||||
/* ── Orbitron (brand mark only) ── */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Orbitron';
|
||||
font-style: normal;
|
||||
@@ -29,3 +30,99 @@
|
||||
src: url('../fonts/orbitron-700-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* ── Manrope — new primary body font (variable, 200..800) ──
|
||||
Covers Latin, Latin-ext, Cyrillic, Cyrillic-ext. CJK falls through to
|
||||
system stack via the body font-family cascade. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 200 800;
|
||||
font-display: swap;
|
||||
src: url('../fonts/manrope-cyrillic-ext.woff2') format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 200 800;
|
||||
font-display: swap;
|
||||
src: url('../fonts/manrope-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 200 800;
|
||||
font-display: swap;
|
||||
src: url('../fonts/manrope-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 200 800;
|
||||
font-display: swap;
|
||||
src: url('../fonts/manrope-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* ── JetBrains Mono — new monospace (variable, 100..800) ──
|
||||
Used for technical labels, badges, metrics, code. Cyrillic-capable so
|
||||
badge text (`CH·01 · WLED`) reads in RU locale. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url('../fonts/jetbrains-mono-cyrillic-ext.woff2') format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url('../fonts/jetbrains-mono-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url('../fonts/jetbrains-mono-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url('../fonts/jetbrains-mono-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* ── Big Shoulders Display — new display font (variable, 100..900) ──
|
||||
Reserved for huge numeric readouts on the dashboard hero + module metric
|
||||
cells. Latin + Latin-ext only; Cyrillic numerics would rarely occur in
|
||||
that position so the system stack is an acceptable fallback. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Big Shoulders Display';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../fonts/big-shoulders-display-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Big Shoulders Display';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../fonts/big-shoulders-display-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -31,11 +31,15 @@ html:has(#tab-graph.active) {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 20;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
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-md, 6px);
|
||||
padding: 4px;
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
box-shadow: var(--lux-shadow-rack, 0 2px 8px var(--shadow-color));
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.graph-toolbar-drag {
|
||||
@@ -406,8 +410,8 @@ html:has(#tab-graph.active) {
|
||||
/* ── Grid background ── */
|
||||
|
||||
.graph-grid-dot {
|
||||
fill: var(--border-color);
|
||||
opacity: 0.3;
|
||||
fill: var(--lux-line, var(--border-color));
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
/* ── Node styles ── */
|
||||
@@ -426,21 +430,24 @@ html:has(#tab-graph.active) {
|
||||
}
|
||||
|
||||
.graph-node-body {
|
||||
fill: var(--card-bg);
|
||||
stroke: none;
|
||||
rx: 8;
|
||||
ry: 8;
|
||||
transition: stroke 0.15s;
|
||||
fill: var(--lux-bg-1, var(--card-bg));
|
||||
stroke: var(--lux-line, var(--border-color));
|
||||
stroke-width: 1;
|
||||
rx: 6;
|
||||
ry: 6;
|
||||
transition: stroke 0.15s, stroke-width 0.15s, filter 0.2s ease;
|
||||
}
|
||||
|
||||
.graph-node:hover .graph-node-body {
|
||||
stroke: var(--text-secondary);
|
||||
stroke: var(--lux-line-bold, var(--text-secondary));
|
||||
stroke-width: 1;
|
||||
filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.graph-node.selected .graph-node-body {
|
||||
stroke: var(--primary-color);
|
||||
stroke: var(--ch-signal, var(--primary-color));
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent));
|
||||
}
|
||||
|
||||
.graph-node-color-bar {
|
||||
@@ -455,37 +462,45 @@ html:has(#tab-graph.active) {
|
||||
}
|
||||
|
||||
.graph-node-title {
|
||||
fill: var(--text-color);
|
||||
fill: var(--lux-ink, var(--text-color));
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
/* Body font, not display — Big Shoulders is condensed and reads as
|
||||
* "stretched" at 12 px in a node label. Display font is for hero
|
||||
* headers only. */
|
||||
font-family: var(--font-body, 'Manrope', 'DM Sans', sans-serif);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.graph-node-subtitle {
|
||||
fill: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
fill: var(--lux-ink-dim, var(--text-secondary));
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono, monospace);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.graph-node-icon {
|
||||
stroke: var(--text-muted);
|
||||
stroke: var(--lux-ink-mute, var(--text-muted));
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.5;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.graph-node.running .graph-node-icon {
|
||||
stroke: var(--primary-color);
|
||||
opacity: 0.85;
|
||||
stroke: var(--ch-signal, var(--primary-color));
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* ── Running indicator (animated gradient border) ── */
|
||||
/* ── Running indicator (animated gradient border + signal-flow glow) ── */
|
||||
|
||||
.graph-node.running .graph-node-body {
|
||||
stroke: url(#running-gradient);
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent));
|
||||
}
|
||||
|
||||
@keyframes graph-running-rotate {
|
||||
@@ -530,13 +545,16 @@ html:has(#tab-graph.active) {
|
||||
/* Port labels — hidden by default, shown on node hover, positioned outside node */
|
||||
.graph-port-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
fill: var(--text-color);
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, monospace);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
fill: var(--lux-ink-dim, var(--text-color));
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
paint-order: stroke fill;
|
||||
stroke: var(--bg-color);
|
||||
stroke: var(--lux-bg-0, var(--bg-color));
|
||||
stroke-width: 3px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
@@ -565,9 +583,9 @@ html:has(#tab-graph.active) {
|
||||
|
||||
.graph-port-drop-target {
|
||||
r: 7 !important;
|
||||
stroke: var(--primary-color) !important;
|
||||
stroke: var(--ch-signal, var(--primary-color)) !important;
|
||||
stroke-width: 3 !important;
|
||||
filter: drop-shadow(0 0 6px var(--primary-color));
|
||||
filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color)));
|
||||
}
|
||||
|
||||
/* ── Edges ── */
|
||||
@@ -705,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;
|
||||
|
||||
@@ -1,47 +1,269 @@
|
||||
:root {
|
||||
--transport-height: 60px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width, 248px) 1fr auto auto;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
height: var(--transport-height, 60px);
|
||||
padding: 0 16px 0 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-sticky);
|
||||
background: var(--bg-color);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
background: linear-gradient(180deg,
|
||||
var(--lux-bg-1, var(--bg-color)) 0%,
|
||||
var(--lux-bg-0, var(--bg-color)) 100%);
|
||||
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
|
||||
}
|
||||
|
||||
/* Accent rule — subtle bottom glow under the transport bar.
|
||||
Uses ::before because ::after is reserved by base.css for the
|
||||
ambient-background blur overlay. */
|
||||
header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: -1px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent) 15%,
|
||||
color-mix(in srgb, var(--ch-cyan, var(--primary-color)) 25%, transparent) 50%,
|
||||
color-mix(in srgb, var(--ch-magenta, var(--primary-color)) 20%, transparent) 85%,
|
||||
transparent 100%);
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
padding: 0 18px;
|
||||
height: 100%;
|
||||
border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Glowing LED brand mark. Rendered as a ::before on .header-title so no
|
||||
HTML change is required. The existing #server-status pulse dot sits
|
||||
inside as the "core" of the mark (see status-badge rule below). */
|
||||
/* LED brand mark — 28 px glowing square with inset dark core.
|
||||
Glow intensity pulses subtly to reinforce the "live instrument" feel. */
|
||||
.header-title::before {
|
||||
content: '';
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
box-shadow:
|
||||
0 0 22px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent),
|
||||
0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 90%, transparent),
|
||||
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
position: relative;
|
||||
animation: brandPulse 4s ease-in-out infinite;
|
||||
}
|
||||
.header-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(18px + 8px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--lux-bg-0, var(--bg-color));
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes brandPulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 22px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent),
|
||||
0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 90%, transparent),
|
||||
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 30px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 70%, transparent),
|
||||
0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 95%, transparent),
|
||||
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
/* Brand stack — title on one line, version under it, no wrap. */
|
||||
.brand-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
line-height: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
-webkit-text-stroke: 0.5px var(--primary-color);
|
||||
white-space: nowrap;
|
||||
-webkit-text-stroke: 0.4px color-mix(in srgb, var(--primary-color) 60%, transparent);
|
||||
paint-order: stroke fill;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
var(--primary-color) 0%,
|
||||
var(--primary-text-color) 35%,
|
||||
var(--primary-color) 50%,
|
||||
var(--primary-text-color) 65%,
|
||||
var(--primary-color) 100%
|
||||
90deg,
|
||||
var(--lux-ink, #e6ebf2) 0%,
|
||||
var(--ch-signal, var(--primary-color)) 50%,
|
||||
var(--lux-ink, #e6ebf2) 100%
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
background-size: 220% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: titleShimmer 6s ease-in-out infinite;
|
||||
animation: titleShimmer 8s linear infinite;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent));
|
||||
}
|
||||
|
||||
.brand-stack #server-version {
|
||||
font-size: 0.6rem;
|
||||
padding: 2px 7px;
|
||||
letter-spacing: 0.25em;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@keyframes titleShimmer {
|
||||
0%, 100% { background-position: 100% 50%; }
|
||||
50% { background-position: 0% 50%; }
|
||||
to { background-position: -220% 50%; }
|
||||
}
|
||||
|
||||
/* ── Transport center: reserved area for armed-status / master-stop /
|
||||
quick-search shortcut. Populated by JS in Phase 3; empty for now. ── */
|
||||
.transport-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 18px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
}
|
||||
|
||||
.transport-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 18px;
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.transport-status.is-armed {
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 10%, transparent);
|
||||
box-shadow:
|
||||
inset 0 0 14px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent),
|
||||
0 0 18px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
|
||||
text-shadow: 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
|
||||
}
|
||||
|
||||
.transport-status .dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 8px currentColor, 0 0 3px currentColor;
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.transport-status:not(.is-armed) .dot {
|
||||
background: var(--lux-ink-faint, var(--text-muted));
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Transport meta — Uptime / CPU / Mem readouts as vertical KEY/VALUE stacks */
|
||||
.transport-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 6px 0 16px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.meta-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
line-height: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-cell .k {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-faint, var(--text-muted));
|
||||
}
|
||||
|
||||
.meta-cell .v {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Interactive meta-cell — clickable variant used by the Poll control.
|
||||
Lightweight hover + focus states so it reads as actionable without
|
||||
looking like a button. */
|
||||
.meta-cell-interactive {
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
margin: 0 -2px;
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
border: var(--lux-hairline, 1px) solid transparent;
|
||||
outline: none;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
.meta-cell-interactive:hover {
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
border-color: var(--lux-line, var(--border-color));
|
||||
}
|
||||
.meta-cell-interactive:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
|
||||
}
|
||||
.meta-cell-interactive:active {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
.meta-cell-interactive .v {
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
text-shadow: 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent);
|
||||
}
|
||||
|
||||
.meta-sep {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--lux-line, var(--border-color));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -53,18 +275,17 @@ h2 {
|
||||
.header-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 3px 4px;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border-color);
|
||||
margin: 0 3px;
|
||||
height: 20px;
|
||||
background: var(--lux-line, var(--border-color));
|
||||
margin: 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -100,28 +321,86 @@ h2 {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
#server-version {
|
||||
/* ── Header locale picker (post IconSelect enhancement) ────────────────────
|
||||
The hidden <select.header-locale> is enhanced into a trigger button at
|
||||
runtime. Inside the toolbar, re-skin it to match .header-btn so it reads
|
||||
as a peer of the icon buttons, with the 2-letter code rendered as a small
|
||||
LED-style accent badge in Orbitron — same display font as the brand mark. */
|
||||
.header-toolbar .icon-select-trigger {
|
||||
width: auto;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.header-toolbar .icon-select-trigger:hover {
|
||||
color: var(--text-color);
|
||||
background: var(--bg-secondary);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.header-toolbar .icon-select-trigger-icon {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
background: var(--border-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
transition: background 0.3s, color 0.3s, box-shadow 0.3s;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--primary-color);
|
||||
padding: 3px 5px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-toolbar .icon-select-trigger-icon > span {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.header-toolbar .icon-select-trigger-label {
|
||||
flex: 0 1 auto;
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-toolbar .icon-select-trigger-arrow {
|
||||
font-size: 0.6rem;
|
||||
opacity: 0.55;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
#server-version {
|
||||
font-family: var(--font-mono, 'Orbitron', sans-serif);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
background: transparent;
|
||||
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, 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 ── */
|
||||
@@ -215,17 +494,27 @@ h2 {
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* #server-status visual hidden — the brand mark itself carries the
|
||||
connection state. When JS adds `.offline`, the mark shifts to coral
|
||||
via the :has() modifier on .header-title below. */
|
||||
.status-badge {
|
||||
font-size: 1rem;
|
||||
animation: pulse 2s infinite;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
color: var(--primary-color);
|
||||
/* Brand mark reflects connection state. Default is the running-color
|
||||
(tracks --ch-signal / --primary-color). When the server-status element
|
||||
has `.offline`, override to coral so the header reads "disconnected"
|
||||
without needing a separate pip. */
|
||||
.header-title:has(#server-status.offline)::before {
|
||||
background: var(--ch-coral, var(--danger-color));
|
||||
box-shadow:
|
||||
0 0 22px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 55%, transparent),
|
||||
0 0 8px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 90%, transparent),
|
||||
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
color: var(--danger-color);
|
||||
.header-title:has(#server-status.offline)::after {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 40%, transparent);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -386,7 +675,8 @@ h2 {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
/* ── Tabs (base styles; sidebar.css re-specializes for vertical rail;
|
||||
mobile.css reverts to a fixed bottom bar on phones) ── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -403,7 +693,10 @@ h2 {
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: color 0.2s ease, border-color 0.25s ease;
|
||||
transition: color 0.2s ease, border-color 0.25s ease, background 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
@@ -469,23 +762,34 @@ h2 {
|
||||
}
|
||||
|
||||
/* Header toolbar buttons */
|
||||
/* Header icon buttons — hairline-bordered squares with channel glow
|
||||
on hover. Mirrors the mockup's `.icon-btn` treatment. */
|
||||
.header-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s, background 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
transition: color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
color: var(--text-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
|
||||
}
|
||||
|
||||
.header-btn .icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
/* Reusable color picker popover */
|
||||
@@ -582,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;
|
||||
@@ -627,8 +899,11 @@ h2 {
|
||||
.cp-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
background: radial-gradient(1000px 600px at 50% 30%,
|
||||
rgba(0, 0, 0, 0.55) 0%,
|
||||
rgba(0, 0, 0, 0.8) 100%);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@@ -637,10 +912,13 @@ h2 {
|
||||
width: 520px;
|
||||
max-width: 90vw;
|
||||
max-height: 60vh;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px var(--shadow-color);
|
||||
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-md, 12px);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02),
|
||||
0 20px 60px rgba(0, 0, 0, 0.5),
|
||||
0 8px 32px var(--shadow-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -648,6 +926,24 @@ h2 {
|
||||
animation: cpSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* Channel-accent rule across the top edge (matches modals) */
|
||||
.cp-dialog::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
var(--ch-signal, var(--primary-color)) 20%,
|
||||
var(--ch-cyan, var(--primary-color)) 50%,
|
||||
var(--ch-magenta, var(--primary-color)) 80%,
|
||||
transparent 100%);
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@keyframes cpSlideDown {
|
||||
from { opacity: 0; transform: translateY(-12px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
@@ -655,18 +951,23 @@ h2 {
|
||||
|
||||
.cp-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
padding: 16px 18px 14px 18px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
font-family: var(--font-body, inherit);
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.cp-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.cp-results {
|
||||
@@ -676,32 +977,38 @@ h2 {
|
||||
}
|
||||
|
||||
.cp-group-header {
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 16px 4px;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
padding: 10px 18px 4px;
|
||||
}
|
||||
|
||||
.cp-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
gap: 10px;
|
||||
padding: 9px 18px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: var(--lux-ink-dim, var(--text-color));
|
||||
}
|
||||
|
||||
.cp-result:hover {
|
||||
background: var(--bg-secondary);
|
||||
background: var(--lux-bg-3, var(--bg-secondary));
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
.cp-result.cp-active {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent) 0%,
|
||||
transparent 100%);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
box-shadow: inset 2px 0 0 var(--ch-signal, var(--primary-color));
|
||||
}
|
||||
|
||||
.cp-result.cp-active .cp-detail {
|
||||
@@ -727,8 +1034,10 @@ h2 {
|
||||
|
||||
.cp-detail {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
}
|
||||
|
||||
.cp-running {
|
||||
@@ -763,36 +1072,100 @@ h2 {
|
||||
}
|
||||
|
||||
.cp-footer {
|
||||
padding: 6px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 18px;
|
||||
border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
text-align: center;
|
||||
background: color-mix(in srgb, var(--lux-bg-0, transparent) 40%, transparent);
|
||||
}
|
||||
|
||||
/* On narrow screens the brand column shrinks to just the mark; on phones
|
||||
the sidebar hides entirely and mobile.css reverts .tab-bar to a fixed
|
||||
bottom strip. */
|
||||
@media (max-width: 1100px) {
|
||||
.tab-btn {
|
||||
padding: 10px 10px;
|
||||
/* Keep all four header children (title | center | meta | toolbar) on one
|
||||
row. Without an explicit 4th track they wrap, doubling the header. */
|
||||
header {
|
||||
grid-template-columns: var(--sidebar-width, 56px) auto 1fr auto;
|
||||
}
|
||||
|
||||
.tab-btn > span[data-i18n] {
|
||||
.header-title {
|
||||
padding: 0 10px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
.header-title h1,
|
||||
#server-version,
|
||||
.header-title::after {
|
||||
display: none;
|
||||
}
|
||||
.transport-center {
|
||||
padding: 0 10px;
|
||||
}
|
||||
/* Tighter meta cluster — drop the trailing separator and shrink gaps */
|
||||
.transport-meta {
|
||||
gap: 10px;
|
||||
padding: 0 4px 0 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.transport-meta .meta-sep:last-child {
|
||||
display: none;
|
||||
}
|
||||
/* Tighter toolbar so it fits beside the meta cluster */
|
||||
.header-toolbar {
|
||||
gap: 2px;
|
||||
}
|
||||
.header-toolbar-sep {
|
||||
margin: 0 2px;
|
||||
}
|
||||
/* Hide secondary header items at narrow widths to free room */
|
||||
.header-link,
|
||||
#tour-restart-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-btn .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet/phone shoulder: the meta cluster still wants ~280px which collides
|
||||
with the toolbar below 900px. Drop CPU + Mem cells (Uptime + Poll stay,
|
||||
they're the most useful at-a-glance signals). */
|
||||
@media (max-width: 900px) {
|
||||
#transport-cpu,
|
||||
#transport-mem {
|
||||
display: none;
|
||||
}
|
||||
.transport-meta .meta-cell:has(#transport-cpu),
|
||||
.transport-meta .meta-cell:has(#transport-mem) {
|
||||
display: none;
|
||||
}
|
||||
.transport-meta > .meta-sep:nth-of-type(1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
header {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
padding: 0 8px 0 0;
|
||||
}
|
||||
.header-title {
|
||||
padding: 0 10px;
|
||||
border-right: none;
|
||||
}
|
||||
.transport-center {
|
||||
display: none;
|
||||
}
|
||||
/* Below the phone breakpoint the sidebar vanishes and the bottom tab
|
||||
bar takes over, so most of the meta cluster goes too. */
|
||||
.transport-meta {
|
||||
display: none;
|
||||
}
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,16 @@
|
||||
max-width: 48px;
|
||||
}
|
||||
|
||||
/* Collapse the enhanced locale trigger to just the 2-letter badge on
|
||||
narrow screens, matching the compact footprint of the icon buttons. */
|
||||
.header-toolbar .icon-select-trigger-label {
|
||||
display: none;
|
||||
}
|
||||
.header-toolbar .icon-select-trigger {
|
||||
gap: 4px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
@@ -148,53 +158,122 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Bottom Tab Bar ── */
|
||||
/* ── Bottom Tab Bar — Lumenworks mobile shell ── */
|
||||
.sidebar .tab-bar,
|
||||
.tab-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: var(--z-sticky);
|
||||
background: var(--card-bg);
|
||||
background: linear-gradient(180deg,
|
||||
var(--lux-bg-1, var(--card-bg)) 0%,
|
||||
var(--lux-bg-0, var(--card-bg)) 100%);
|
||||
border-bottom: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-around;
|
||||
padding: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
box-shadow: 0 -2px 8px var(--shadow-color);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.3);
|
||||
gap: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Top channel-accent rule — matches the transport bar bottom rule so
|
||||
the two bars feel like bookends of the mobile layout. */
|
||||
.sidebar .tab-bar::before,
|
||||
.tab-bar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 28%, transparent) 15%,
|
||||
color-mix(in srgb, var(--ch-cyan, var(--info-color)) 24%, transparent) 50%,
|
||||
color-mix(in srgb, var(--ch-magenta, #ff4ade) 20%, transparent) 85%,
|
||||
transparent 100%);
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar .tab-btn,
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 4px 6px;
|
||||
font-size: 0.65rem;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 7px 4px 6px;
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
border-bottom: none;
|
||||
border-top: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
margin-bottom: 0;
|
||||
position: relative;
|
||||
grid-template-columns: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar .tab-btn.active,
|
||||
.tab-btn.active {
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
border-bottom-color: transparent;
|
||||
border-top-color: var(--primary-color);
|
||||
border-top-color: var(--ch-signal, var(--primary-color));
|
||||
background: linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent) 0%,
|
||||
transparent 60%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* LED pip above the icon on the active tab (replaces the left-stripe
|
||||
since the sidebar's box-shadow doesn't carry here). */
|
||||
.sidebar .tab-btn.active::before,
|
||||
.tab-btn.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
box-shadow: 0 0 6px var(--ch-signal, var(--primary-color));
|
||||
}
|
||||
|
||||
.sidebar .tab-btn .icon,
|
||||
.tab-btn .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidebar .tab-btn.active .icon,
|
||||
.tab-btn.active .icon {
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
}
|
||||
|
||||
.sidebar .tab-btn > span[data-i18n],
|
||||
.tab-btn > span[data-i18n] {
|
||||
font-size: 0.6rem;
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -205,13 +284,19 @@
|
||||
/* Tab badge repositioned to top-right of icon */
|
||||
.tab-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: calc(50% - 18px);
|
||||
font-size: 0.55rem;
|
||||
top: 6px;
|
||||
right: calc(50% - 20px);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.48rem;
|
||||
font-weight: 700;
|
||||
padding: 0 4px;
|
||||
min-width: 14px;
|
||||
line-height: 1.2;
|
||||
min-width: 12px;
|
||||
line-height: 1.3;
|
||||
margin-left: 0;
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
color: var(--lux-bg-0, var(--primary-contrast));
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Body padding for fixed bottom bar */
|
||||
@@ -266,6 +351,12 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Hide the bottom-right corner bracket on fullscreen mobile modals —
|
||||
there's no "panel" to decorate. Top channel rule stays. */
|
||||
.modal-content::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content-wide {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
@@ -274,11 +365,16 @@
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 12px 14px 10px;
|
||||
padding: 14px 14px 12px 20px;
|
||||
}
|
||||
|
||||
.modal-header::before {
|
||||
left: 8px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.15rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@@ -385,11 +481,6 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.app-footer {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
/* Command palette */
|
||||
#command-palette {
|
||||
padding-top: 5vh;
|
||||
|
||||
+3415
-372
File diff suppressed because it is too large
Load Diff
@@ -42,21 +42,30 @@
|
||||
}
|
||||
|
||||
.stream-card-prop {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--border-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.68rem;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
background: var(--lux-bg-0, var(--border-color));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
padding: 3px 8px;
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
max-width: 220px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stream-card-prop .icon {
|
||||
color: var(--primary-text-color);
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stream-card-prop-full {
|
||||
@@ -65,18 +74,19 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.stream-card-link {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.stream-card-link:hover {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 15%, transparent);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, var(--lux-line, var(--border-color)));
|
||||
}
|
||||
|
||||
.stream-card-link:hover .icon {
|
||||
@@ -84,15 +94,31 @@
|
||||
}
|
||||
|
||||
@keyframes cardHighlight {
|
||||
0%, 100% { box-shadow: none; }
|
||||
25%, 75% { box-shadow: 0 0 0 3px var(--primary-color), 0 0 20px rgba(var(--primary-rgb, 59, 130, 246), 0.3); }
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 0 0 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent),
|
||||
0 0 0 0 transparent;
|
||||
}
|
||||
25%, 75% {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ch-signal, var(--primary-color)),
|
||||
0 0 32px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent),
|
||||
0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 80%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.card-highlight,
|
||||
.template-card.card-highlight {
|
||||
animation: cardHighlight 2s ease-in-out;
|
||||
.template-card.card-highlight,
|
||||
.dashboard-target.card-highlight {
|
||||
animation: cardHighlight 2.2s ease-in-out;
|
||||
position: relative;
|
||||
z-index: 11;
|
||||
/* Nudge the card forward during the highlight so the outer glow
|
||||
isn't clipped by a containing overflow: hidden (strip cells,
|
||||
tree-nav panels). Box-shadow is never clipped by the element's
|
||||
own overflow but *is* clipped by parent overflow in stacking
|
||||
contexts where the card doesn't escape. */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Dim overlay behind highlighted card */
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/* ── Lumenworks sidebar (channel-strip nav) ─────────────────────
|
||||
Primary navigation for desktop/tablet. Contains the 6 top-level
|
||||
tabs (.tab-bar kept for JS compatibility with switchTab), a live
|
||||
meter plate at the bottom, and collapses to a 56px icon rail
|
||||
between 1100px and 600px. On phones (<=600px) the sidebar hides
|
||||
entirely and mobile.css reverts .tab-bar to a fixed bottom strip.
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── App shell: header on top, 2-column body below ── */
|
||||
|
||||
.app-body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width, 248px) 1fr;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
min-height: calc(100vh - var(--transport-height, 64px));
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-width: 0; /* allow children to shrink instead of overflow */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Sidebar container ── */
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: var(--transport-height, 64px);
|
||||
height: calc(100vh - var(--transport-height, 64px));
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
border-right: var(--lux-hairline) solid var(--lux-line-bold, var(--border-color));
|
||||
background: linear-gradient(180deg, var(--lux-bg-1, var(--bg-secondary)) 0%, var(--lux-bg-0, var(--bg-color)) 100%);
|
||||
padding: 16px 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
z-index: calc(var(--z-sticky) - 1);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
padding: 0 8px 8px;
|
||||
border-bottom: 1px dashed var(--lux-line, var(--border-color));
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar-label::before { content: '['; color: var(--lux-ink-faint, var(--text-muted)); }
|
||||
.sidebar-label::after { content: ']'; color: var(--lux-ink-faint, var(--text-muted)); margin-left: auto; }
|
||||
|
||||
/* ── Tab-bar (kept as vertical nav inside sidebar on desktop) ── */
|
||||
|
||||
.sidebar .tab-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar .tab-btn {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 9px 10px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar .tab-btn:hover {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.sidebar .tab-btn.active {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent) 0%,
|
||||
transparent 80%);
|
||||
box-shadow: inset 2px 0 0 var(--ch-signal, var(--primary-color));
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.sidebar .tab-btn.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 60%;
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
box-shadow: var(--lux-signal-glow, 0 0 6px currentColor);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sidebar .tab-btn .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar .tab-btn.active .icon {
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
}
|
||||
|
||||
.sidebar .tab-btn > span[data-i18n] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar .tab-btn .tab-badge {
|
||||
background: var(--lux-bg-3, var(--border-color));
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
min-width: 20px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
.sidebar .tab-btn.active .tab-badge {
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
color: var(--lux-bg-0, #000);
|
||||
}
|
||||
|
||||
/* ── Sidebar footer: live CPU/FPS meter plate ── */
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
padding: 14px 20px 8px;
|
||||
border-top: 1px dashed var(--lux-line, var(--border-color));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cpu-meter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.cpu-meter-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
}
|
||||
|
||||
.cpu-meter-row b {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.cpu-bar {
|
||||
height: 3px;
|
||||
background: var(--lux-bg-3, var(--border-color));
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cpu-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--ch-signal, var(--primary-color)), var(--ch-cyan, var(--info-color)));
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
|
||||
transition: width 0.4s ease;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.cpu-bar-fps > i {
|
||||
background: linear-gradient(90deg, var(--ch-cyan, var(--info-color)), var(--ch-magenta, #ff4ade));
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-cyan, var(--info-color)) 50%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-version {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-faint, var(--text-muted));
|
||||
text-align: center;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed var(--lux-line, var(--border-color));
|
||||
}
|
||||
|
||||
/* ── Responsive: icon rail at tablet-desktop, hidden at phone ── */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
:root { --sidebar-width: 56px; }
|
||||
|
||||
.sidebar {
|
||||
padding: 14px 0 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
.sidebar-section {
|
||||
padding: 0 6px;
|
||||
}
|
||||
.sidebar-label,
|
||||
.sidebar-version {
|
||||
display: none;
|
||||
}
|
||||
.sidebar-foot {
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
.sidebar .tab-btn {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 10px 2px;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
/* Two-line caption with tight tracking — single-line ellipsis truncates
|
||||
longer labels like "Automations"/"Integrations" to "AUTOMA…" which
|
||||
isn't recoverable; two short lines are uglier per word but legible. */
|
||||
.sidebar .tab-btn > span[data-i18n] {
|
||||
font-size: 0.46rem;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.1;
|
||||
text-transform: uppercase;
|
||||
color: inherit;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.sidebar .tab-btn .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.sidebar .tab-btn .tab-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
font-size: 0.55rem;
|
||||
min-width: 14px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.cpu-meter-row {
|
||||
font-size: 0.48rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.cpu-meter-row b {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
/* On phones, sidebar disappears and mobile.css reverts .tab-bar to
|
||||
a fixed bottom strip. The .app-body grid becomes a single column. */
|
||||
:root { --sidebar-width: 0px; }
|
||||
|
||||
.app-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
/* Hide sidebar chrome; .tab-bar inside still gets fixed-bottom
|
||||
styling from mobile.css regardless of its container. */
|
||||
position: static;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
display: contents;
|
||||
}
|
||||
.sidebar-foot,
|
||||
.sidebar-label {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,63 @@
|
||||
}
|
||||
|
||||
.template-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
--ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */
|
||||
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;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Channel stripe on left edge — opt-in only (mirrors .card::before in
|
||||
* cards.css). Idle template-cards without a custom color stay clean.
|
||||
* The Add card never gets a stripe (it's not an entity). */
|
||||
.template-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--ch);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--ch) 40%, transparent);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transition: width 0.2s ease, box-shadow 0.2s ease;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.template-card[data-has-color="1"]::before,
|
||||
.template-card.card-running::before {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.add-template-card::before { display: none !important; }
|
||||
|
||||
/* Corner bracket — silkscreened panel feel in the top-right */
|
||||
.template-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px; right: 8px;
|
||||
width: 12px; height: 12px;
|
||||
border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
|
||||
border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--lux-shadow-rack, 0 8px 24px var(--shadow-color));
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--lux-line-bold, var(--border-color));
|
||||
}
|
||||
|
||||
.template-card:hover::before {
|
||||
width: 4px;
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent);
|
||||
}
|
||||
|
||||
.add-template-card {
|
||||
@@ -93,13 +137,19 @@
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
border: var(--lux-hairline, 1px) solid transparent;
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
@@ -606,36 +656,44 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
transition: color 0.2s ease, border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.stream-tab-btn:hover {
|
||||
color: var(--text-color);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
.stream-tab-btn.active {
|
||||
color: var(--primary-text-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
border-bottom-color: var(--ch-signal, var(--primary-color));
|
||||
text-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent);
|
||||
}
|
||||
|
||||
.stream-tab-count {
|
||||
background: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background: var(--lux-bg-3, var(--border-color));
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
border-radius: 2px;
|
||||
margin-left: 4px;
|
||||
letter-spacing: 0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stream-tab-btn.active .stream-tab-count {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
color: var(--lux-bg-0, var(--primary-contrast));
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
|
||||
}
|
||||
|
||||
.cs-expand-collapse-group {
|
||||
@@ -685,11 +743,26 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
}
|
||||
|
||||
.subtab-section-header {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 12px 0;
|
||||
padding-bottom: 8px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
margin: 0 0 16px 0;
|
||||
padding-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25em;
|
||||
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.subtab-section-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; bottom: -1px;
|
||||
width: 48px;
|
||||
height: 1px;
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
|
||||
}
|
||||
|
||||
.subtab-section-header.cs-header {
|
||||
@@ -731,13 +804,16 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
}
|
||||
|
||||
.cs-count {
|
||||
background: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 0 7px;
|
||||
font-size: 0.75rem;
|
||||
background: var(--lux-bg-3, var(--border-color));
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
border-radius: 2px;
|
||||
padding: 2px 7px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.cs-collapsed .cs-filter-wrap,
|
||||
@@ -758,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 */
|
||||
|
||||
@@ -22,30 +22,53 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Trigger bar ── */
|
||||
/* ── Trigger bar — module selector pill ── */
|
||||
|
||||
.tree-dd-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
background: var(--lux-bg-1, var(--bg-secondary));
|
||||
user-select: none;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-color);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Channel stripe on the left edge of the trigger */
|
||||
.tree-dd-trigger::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.tree-dd-trigger:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 6%, var(--bg-secondary));
|
||||
border-color: var(--lux-line-bold, var(--border-color));
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
}
|
||||
|
||||
.tree-dd-trigger:hover::before,
|
||||
.tree-dd-trigger.open::before {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 60%, transparent);
|
||||
}
|
||||
|
||||
.tree-dd-trigger.open {
|
||||
border-color: var(--primary-color);
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, var(--lux-line, var(--border-color)));
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
}
|
||||
|
||||
.tree-dd-trigger-icon {
|
||||
@@ -60,18 +83,24 @@
|
||||
|
||||
.tree-dd-trigger-title {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
white-space: nowrap;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
.tree-dd-trigger-count {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
min-width: 16px;
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
color: var(--lux-bg-0, var(--primary-contrast));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 2px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.04em;
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
|
||||
}
|
||||
|
||||
.tree-dd-chevron {
|
||||
@@ -94,24 +123,43 @@
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* ── Dropdown panel ── */
|
||||
/* ── Dropdown panel — rack-selector popover ── */
|
||||
|
||||
.tree-dd-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 240px;
|
||||
max-width: 340px;
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
background: linear-gradient(180deg,
|
||||
var(--lux-bg-1, var(--bg-color)) 0%,
|
||||
var(--lux-bg-2, var(--bg-color)) 100%);
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
|
||||
border-radius: var(--lux-r-md, 6px);
|
||||
box-shadow: var(--lux-shadow-rack, 0 8px 24px rgba(0, 0, 0, 0.25));
|
||||
z-index: 100;
|
||||
padding: 4px 0;
|
||||
margin-top: 4px;
|
||||
padding: 6px 0;
|
||||
margin-top: 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Channel accent rule at the top of the panel */
|
||||
.tree-dd-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
var(--ch-signal, var(--primary-color)) 20%,
|
||||
var(--ch-cyan, var(--primary-color)) 50%,
|
||||
var(--ch-magenta, var(--primary-color)) 80%,
|
||||
transparent 100%);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tree-dd-panel.open {
|
||||
@@ -123,14 +171,26 @@
|
||||
.tree-dd-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px 3px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
gap: 8px;
|
||||
padding: 8px 14px 4px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 600;
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.22em;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Small square dot prefix — reads like a silkscreened section marker. */
|
||||
.tree-dd-group-header::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: var(--lux-ink-faint, var(--text-muted));
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-dd-group-header.tree-dd-depth-1 {
|
||||
@@ -184,12 +244,15 @@
|
||||
.tree-dd-leaf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px 5px 20px;
|
||||
gap: 8px;
|
||||
padding: 7px 14px 7px 22px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.1s, background 0.1s;
|
||||
font-family: var(--font-body, inherit);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
transition: color 0.1s, background 0.1s, box-shadow 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Indent leaves inside nested groups */
|
||||
@@ -203,19 +266,38 @@
|
||||
}
|
||||
|
||||
.tree-dd-leaf:hover {
|
||||
color: var(--text-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: var(--lux-bg-3, var(--bg-secondary));
|
||||
}
|
||||
|
||||
/* Active leaf: LED pip on the left + channel glow + brighter text */
|
||||
.tree-dd-leaf.active {
|
||||
color: var(--primary-text-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent) 0%,
|
||||
transparent 80%);
|
||||
font-weight: 600;
|
||||
box-shadow: inset 2px 0 0 var(--ch-signal, var(--primary-color));
|
||||
}
|
||||
|
||||
.tree-dd-leaf.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
box-shadow: 0 0 6px var(--ch-signal, var(--primary-color));
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tree-dd-leaf.active .tree-count {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
background: var(--ch-signal, var(--primary-color));
|
||||
color: var(--lux-bg-0, var(--primary-contrast));
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
|
||||
}
|
||||
|
||||
.tree-dd-leaf .tree-node-icon {
|
||||
@@ -238,22 +320,26 @@
|
||||
|
||||
/* ── Count badge (shared) ── */
|
||||
|
||||
.tree-count {
|
||||
background: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.6rem;
|
||||
.tree-count,
|
||||
.tree-dd-group-count {
|
||||
background: var(--lux-bg-3, var(--border-color));
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 600;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
min-width: 16px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Group separator ── */
|
||||
/* ── Group separator — hairline-dashed between top-level groups ── */
|
||||
|
||||
.tree-dd-group + .tree-dd-group {
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 2px;
|
||||
padding-top: 2px;
|
||||
border-top: 1px dashed var(--lux-line, var(--border-color));
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user