Compare commits

..

11 Commits

Author SHA1 Message Date
alexei.dolgolyov 6aeda935f1 chore: release v0.8.1
Build Android APK / build-android (push) Failing after 11s
Build Release / create-release (push) Successful in 6s
Build Release / build-docker (push) Successful in 2m57s
Build Release / build-linux (push) Successful in 1m49s
Build Release / build-windows (push) Successful in 3m35s
Build Release / publish-release (push) Successful in 2s
2026-05-28 23:35:35 +03:00
alexei.dolgolyov a5effba553 feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers
Backend
- snapshot: GET /api/v1/snapshot aggregates targets, devices, sources,
  presets and system into one payload for the HA coordinator, collapsing
  the prior ~2N+M request fan-out; per-section ?include= gating.
- graph: GET /api/v1/graph{,/schema,/dependents} backed by a pure,
  unit-tested graph_schema engine — one authoritative connectable-field
  registry so the editor no longer hard-codes topology in two places.
- devices: thread mqtt_source_id through DeviceCreate/Update/Response and
  the routes for multi-broker MQTT; shared validate_mqtt_source_exists
  (_mqtt_validation.py) reused by device + output-target routes; stop
  update_device masking intentional 4xx as 500.
- shutdown: bound uvicorn graceful-shutdown via GRACEFUL_SHUTDOWN_TIMEOUT
  (shared by __main__, android_entry, demo) so a lingering events WebSocket
  can't strand LED targets or block process exit.
- access log: structured _access_log middleware attributing each request to
  its authenticated token label (never the secret); uvicorn access_log off.

Frontend
- graph editor: generic schema-driven port/edge rendering, layout and
  connection handling; service-worker refresh.
- device modals: MQTT broker EntitySelect for device_type=mqtt in add-device
  and settings, wired into load/save/validate/dirty-check/clone.
- i18n: en/ru/zh keys.

Tests: graph routes + schema, snapshot routes, access log, mqtt_source_id
device regressions, bounded-shutdown entrypoint. 1614 passed.
2026-05-28 22:51:04 +03:00
alexei.dolgolyov b83a72e63f chore: release v0.8.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 8s
Build Release / build-linux (push) Successful in 3m8s
Build Release / build-docker (push) Successful in 4m11s
Build Release / build-windows (push) Successful in 4m55s
Build Release / publish-release (push) Successful in 1s
2026-05-28 17:48:06 +03:00
alexei.dolgolyov 0d840adfca fix(ctypes): share wintypes.MSG with platform_detector to avoid argtype races
WindowsShutdownGuard was binding user32.GetMessageW.argtypes with
POINTER(_MSG) (project-local struct), while PlatformDetector's display-
power monitor binds it with POINTER(wintypes.MSG). argtypes is a
mutable global on the cached WinDLL handle, so whichever module
imported last won, and the other module's byref() then tripped
Python 3.13's strict argtype check with
"expected LP_MSG instance instead of pointer to _MSG".

The two structs are byte-identical (same field types in the same
order, just pt vs pt_x/pt_y naming) and we never touch the pt field,
so aliasing _MSG to wintypes.MSG eliminates the conflict — both
modules now bind the same POINTER class, the writes become idempotent,
and the full test suite passes regardless of import order.

CI runs on Linux so this never fired in release builds, but it broke
the local Windows test run.
2026-05-28 17:36:19 +03:00
alexei.dolgolyov 1f959932c1 fix(notification): allow clearing the sound on per-app overrides and main row
Previously, both the per-app override sound dropdown and the main
notification sound row only attached an EntitySelect when at least
one sound asset was registered — and never with allowNone. That left
users with no way to pick "no sound" once an entry existed, and made
the override dropdown silently inert before any assets were added.

Always construct the EntitySelect (so an empty assets list still
renders a usable, searchable input) and pass allowNone with the
localized none label so "no sound" is a first-class choice in both
the override list and the main row.
2026-05-28 17:28:34 +03:00
alexei.dolgolyov 10eb24b2ce docs: dashboard innerHTML reconciliation review notes
Design doc capturing the architectural pattern behind the perf-card
flicker (every innerHTML rewrite tears down the subtree, restarting
CSS animations / dropping focus / wasting layout) and the two
drivers — poll timers and server:* push events. Inventories every
remaining latent site in perf-charts.ts / dashboard.ts /
entity-events.ts / game-integration.ts, walks the decision ladder
from a setInnerHtmlIfChanged helper through hand-rolled cell
components to a Lit migration, and lands on Lit for polling-heavy
modules with entity-events.ts tab reconciliation sequenced ahead of
the dashboard cards because of its higher blast radius.

Planning artifact only — no implementation here.
2026-05-28 17:26:56 +03:00
alexei.dolgolyov 66b85b0175 fix(css-editor): persist notification_sound + notification_volume
The CSS editor modal collected every other notification field on
save but silently dropped notification_sound and notification_volume,
so toggling them in the modal had no effect on the saved strip.
Include both in the save payload alongside the existing notification
fields.
2026-05-28 17:26:44 +03:00
alexei.dolgolyov bc42604045 ci(release): publish release only after every build job uploads assets
create-release now creates the release as a draft so users never see
a release page that's missing artifacts (or, worse, missing the
sha256 sidecars that the in-app updater requires). A new
publish-release job runs after create-release, build-windows,
build-linux, and build-docker all succeed, and PATCHes the release
to draft=false in one step. If any build fails, the draft stays
hidden and can be deleted manually.
2026-05-28 17:26:28 +03:00
alexei.dolgolyov 3645216669 feat(icons): spectrum aperture icon set + dedicated tray variant
Regenerate the LedGrab icon family from a single Pillow script
(build/generate_icon.py): a rounded-square aperture traced by a
continuous RGB color-wheel stroke over a vignette canvas with a soft
chromatic bloom. 4x supersampled then downsampled per output for
crispness. Outputs 192/512 standard, 512 maskable (safe-area padded
for PWA round-crops), and a new 256 transparent-background tray
variant so the taskbar icon reads cleanly against light themes
instead of showing a dark tile.

icon.ico now embeds 16/24/32/48/64/128/256 frames sourced from the
transparent tray master, fixing the dark-square halo around the
file/taskbar icon on light Windows themes.

__main__ picks icon-tray.png for the tray and falls back to
icon-192.png when the tray asset isn't present (older bundles /
forks).
2026-05-28 17:26:18 +03:00
alexei.dolgolyov 85da2e538d feat(backup): bundle assets in ZIP + partial-write hardening + restart log
Auto-backups now produce a ZIP containing ledgrab.db plus every file
in the assets dir under assets/ — matching the manual
GET /api/v1/system/backup format, so restore accepts either output
interchangeably. Legacy .db backups remain listable, restorable, and
prunable; both extensions count toward max_backups.

Writes stage to <name>.partial then os.replace into place — a crash
mid-ZIP never leaves a half-written backup that masquerades as valid.
Stale .partials from prior crashes are swept on the next run.
Symlinks inside the assets dir are skipped so a hostile link can't
slurp a target outside the dir into every backup. Backups larger than
500 MB log a warning so operators notice unbounded asset growth before
disk fills up.

restart.py: redirect the spawned restart script's stdout/stderr to
restart.log and bail out early if the script is missing — silent
failures (PowerShell off PATH, restart.ps1 erroring) used to vanish
into a detached child with no diagnostic trail.

Tests cover happy path, asset bytes round-trip, partial cleanup,
None/missing assets_dir, failure rollback, stale-partial sweep,
symlink rejection, mixed legacy+new listing, and cross-format prune.
2026-05-28 17:25:55 +03:00
alexei.dolgolyov e4d24a02da fix(ctypes): pin LPMSG across MSG-pump prototypes for Python 3.13
Python 3.13's ctypes tightened argtypes checks and now rejects
mismatched POINTER(MSG) cache entries — each call to POINTER(MSG)
can return a class identity that doesn't match what byref() of an
instance produces, raising "expected LP_MSG instance instead of
pointer to _MSG" inside GetMessageW/TranslateMessage/DispatchMessageW.

Capture POINTER(MSG) once into LPMSG and reuse the same class object
across all three prototypes in both the WindowsShutdownGuard pump and
the PlatformDetector display-power monitor. Restores the 4 failing
win_shutdown tests.
2026-05-28 17:25:37 +03:00
57 changed files with 4100 additions and 347 deletions
+26 -1
View File
@@ -98,6 +98,9 @@ jobs:
print(json.dumps('\n\n'.join(sections))) print(json.dumps('\n\n'.join(sections)))
") ")
# Created as draft so the release isn't user-visible until every
# build job has attached its assets. The publish-release job at
# the end of the workflow flips draft=false once all builds pass.
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \ RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -105,7 +108,7 @@ jobs:
\"tag_name\": \"$TAG\", \"tag_name\": \"$TAG\",
\"name\": \"LedGrab $TAG\", \"name\": \"LedGrab $TAG\",
\"body\": $BODY_JSON, \"body\": $BODY_JSON,
\"draft\": false, \"draft\": true,
\"prerelease\": $IS_PRE \"prerelease\": $IS_PRE
}") }")
@@ -350,3 +353,25 @@ jobs:
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
docker push "$REGISTRY:latest" docker push "$REGISTRY:latest"
fi fi
# ── Publish the release (flip draft=false) ─────────────────
# Runs only after every build job succeeded so users never see a
# release that's missing artifacts or sha256 sidecars (the in-app
# updater refuses to install without them).
publish-release:
needs: [create-release, build-windows, build-linux, build-docker]
if: github.event_name == 'push' && success()
runs-on: ubuntu-latest
steps:
- name: Promote draft release to published
env:
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
curl -s -X PATCH "$BASE_URL/releases/$RELEASE_ID" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"draft": false}'
echo "Published release $RELEASE_ID"
+26 -139
View File
@@ -1,167 +1,54 @@
## v0.7.0 (2026-05-26) ## v0.8.1 (2026-05-28)
A device-support release: **seven new device families**, a unified **pairing UX**, ### User-facing changes
a brand-new **HTTP-endpoint** output type, **multi-broker MQTT + Zigbee2MQTT**
support, a major **shutdown / data-safety** fix, and a deep architectural
refactor pass that landed registry patterns for every dispatch hot path.
### Features #### Features
#### New device types ##### Multi-broker MQTT devices
- **DDP** — standalone Open-Pixel-Control-style target for Pixelblaze / ESPixelStick / xLights / Falcon endpoints, port 4048 ([8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a)) - The device editor now shows an MQTT **broker picker** for `device_type=mqtt` (in both the add-device and device-settings modals), wired into load / save / validate / dirty-check / clone. An empty selection means "first available broker"
- **Yeelight** — Xiaomi/Yeelight bulbs and lightstrips over JSON-RPC on port 55443, SSDP discovery ([4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005)) - `mqtt_source_id` is now threaded end-to-end through `DeviceCreate` / `DeviceUpdate` / `DeviceResponse` and the device routes; the referenced broker is validated on create **and** update ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **WiZ Connected** — Philips WiZ smart bulbs over UDP on port 38899, broadcast discovery ([ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b))
- **LIFX** — LIFX bulbs and lightstrips over the binary LIFX LAN protocol on port 56700 ([8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490))
- **Govee LAN** — Govee Wi-Fi bulbs and ambient kits, multicast discovery (requires "LAN Control" enabled in the Govee Home app) ([887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d))
- **Open Pixel Control (OPC)** — Fadecandy boards, xLights/Falcon, OPC bridges, port 7890 with channel addressing ([31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a))
- **Nanoleaf** — Light Panels / Canvas / Shapes / Lines / Elements over the documented HTTP REST API on port 16021 ([426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a))
#### New output type ##### Schema-driven wiring-graph editor
- **HTTP endpoint output target** — POST live strip frames to any user-configured HTTP endpoint, alongside WLED / MQTT / Hue. Full editor + storage + routes ([d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800)) - The visual graph editor now renders ports and edges generically from a backend-served schema (`GET /api/v1/graph/schema`) instead of hard-coding the connectable-field topology in two places — so client and server can no longer drift
- New `GET /api/v1/graph` returns the full nodes + edges + validation topology, and `GET /api/v1/graph/dependents/{kind}/{id}` reports what references an entity ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
#### Pairing flow ##### Aggregated snapshot endpoint
- Generic **pairing UX scaffold** — 30-second SVG ring + countdown, instructions, retry/cancel. First concrete consumer is Nanoleaf; Tuya/Twinkly slot into the same shape later ([2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680)) - New `GET /api/v1/snapshot` returns all output targets (with processing state + metrics), devices (with brightness), the source / preset / clock lists, and the system block in a **single response** — collapsing the Home Assistant integration's previous ~2N+M request fan-out into one round trip
- `?include=` fetches only a subset of sections, and an excluded section also skips its server-side work (e.g. cold-cache hardware brightness probes or the blocking NVML performance query) ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
#### MQTT / Zigbee2MQTT #### Bug Fixes
- **Multi-broker MQTT** + new **Zigbee2MQTT light output target** sharing the HA-Light editor. Legacy single-broker YAML/env config auto-migrates to a "Default Broker" MQTTSource on startup ([530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c)) - **Graceful shutdown no longer hangs:** uvicorn's graceful-shutdown wait is now bounded (`GRACEFUL_SHUTDOWN_TIMEOUT`, shared by the desktop, Android, and demo launchers). A lingering events WebSocket (which the browser auto-reconnects) used to keep connections from draining, so the lifespan shutdown never ran — leaving LED targets lit and blocking process exit. Ctrl+C / OS shutdown with the UI open now reliably stops targets and checkpoints the DB ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **Device update error codes:** `update_device` no longer masks an intentional 4xx (e.g. an unknown `mqtt_source_id` or failed group validation) as a generic 500 ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
#### Editor experience
- **Live preview** for color-strip sources of every type that can render without external calibration (audio, math_wave, weather, game_event, api_input, mapped, composite, processed) ([337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c))
- **Expanded automations** — new rule shapes + matching UI inputs + 285 lines of dispatch coverage ([3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8))
- **Expanded value sources** — storage + schema + UI for the new value-source kinds the per-type factory refactor introduced ([737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72))
- **Card icon picker expanded** from 44 → 120 icons across 5 new categories (weather, nature, controls, status, office) ([cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94))
- **closeIfPristine** modal save-guard — editing an unchanged entity now silently closes the modal instead of firing a misleading "updated" toast ([f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30))
- New **MiniSelect** primitive for compact dropdowns that don't justify the full IconSelect grid; **IconSelect** gains a defence-in-depth XSS sanitiser on the icon channel ([9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd), [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138))
#### Updater
- **SSRF-validated redirect chain** in the update service so a hostile mirror can't bounce the updater to a private IP. Stricter `restart.ps1` argument handling + clearer logs ([45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2))
### Bug Fixes
- **Survive PC restart** — SQLite was running WAL with `synchronous=NORMAL` and `Database.close()` was never called, so an unclean Windows shutdown rolled the DB back to the last checkpoint and silently lost recent edits. Now uses `synchronous=FULL` + `wal_autocheckpoint=100` + explicit `wal_checkpoint(TRUNCATE)` on close, and a hidden WM_QUERYENDSESSION / WM_ENDSESSION window keeps Windows from force-killing the process before the lifespan can finish ([e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3))
- **Devices PATCH preserves URL** — PATCH-without-`url` (rename / icon-only) used to drop the address into the processor as None ([0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43))
- **HA Light brightness scale** — `_send_entity_color` was double-applying `brightness_scale` below 1 (quartered output for a half-scale) and skipping it above 1 (boost lost). Now one `clamp(max(r,g,b) * bs * vs, 0, 255)` pass with regression coverage ([ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60))
- **Dashboard "MODIFIED" badge** no longer fires retroactively on un-edited legacy layouts — `userModified` is now driven by actual edits, not deep-equal drift from defaults ([e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d))
- **Transport-bar uptime** repaints on `/health` response instead of waiting up to ~10 s for the next poll ([f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e))
- **Pre-merge device-support review pass** — `update_device` no longer double-encrypts secrets in memory; `GET /devices` strips paired-only secrets behind boolean flags; SSRF validation on every new driver; corrupt-envelope decrypt returns `""` instead of deleting the device row; `update_device` URL trim matches create; Govee discovery port-4002 collision serialised behind a module lock; Nanoleaf mDNS scan cleans up tasks on cancel; pair endpoint stops logging userinfo / exception bodies ([0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78))
- **value_source factory contract** — `_build_game_event` raises `NotImplementedError` (preserves the historical store contract) and `create_source` runs `build_source` before `_check_name_unique` so an invalid `source_type` raises the right error ([c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb))
- **`utils/url_scheme` + `utils/net_classify`** were referenced but untracked on a clean checkout — server failed to start with `ModuleNotFoundError`. Now committed ([7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6))
### Performance
- **Capture hot paths vectorised** — WGC swaps per-frame ~30 MB BGRA→RGB fancy-index allocations for `cv2.cvtColor` into a 3-slot pre-allocated pool; MSS uses `screenshot.raw + cv2.cvtColor` with 256-byte change-detection; DXcam/BetterCam fixes a silent name-mangling factory leak; dominant-colour reduction is ~10× faster via packed-RGB `np.bincount` ([f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0))
- **Event-driven frame hand-off** — `LiveStream` gains a `frame_id` + `Condition`, consumers wait instead of polling, ring buffer grows 3 → 5 slots, `_blend_u16` uses `cv2.addWeighted`. Up to one `frame_time` of glass-to-LED latency saved at matched FPS ([ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81))
- **WLED brightness threshold** caches per-frame `np.max` keyed on frame identity instead of reducing the LED array every loop ([6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6))
- **Dashboard FPS charts** now diff target ids and only recreate added/removed/detached charts (skipping the history fetch when local samples already exist), and spark SVGs are mutated in place instead of `innerHTML`-rewritten every poll. Memoised patches/devices rendering by content signature so unchanged ticks no longer restart CSS animations ([f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9))
--- ---
### Development / Internal ### Development / Internal
#### Architecture audit — registry patterns everywhere #### Backend
- **Color-strip stream dispatch** — `ColorStripStreamManager.acquire()` and `ws_stream._create_stream()` now share a `STREAM_BUILDERS` registry keyed by source type, with import-time coverage assertion against `_SOURCE_TYPE_MAP`. CSS response builder gets the same treatment ([563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac)) - **Wiring-graph schema engine** (`api/graph_schema.py`): a pure, unit-tested module that is the single source of truth for which reference fields connect which entity kinds; builds the topology and performs dependency lookup plus cycle / dangling-reference detection without booting the app or any store. The route layer only gathers serialized entities and delegates ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **Value-source create / update** — `ValueSourceStore.create_source` shrinks from ~260 → ~25 lines via per-type builder/applier functions in a new `storage.value_source_factories` module ([3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e)) - **Structured access log:** a new middleware emits one structured line per request, attributing it to the authenticated token's friendly label (the key name, **never** the secret) so traffic can be traced to a client (e.g. `homeassistant` vs `android`). uvicorn's own access log is disabled to avoid duplicate lines ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **SystemMetricsValueStream** — three parallel `if/elif` chains collapse into a `MetricSpec(name, read_psutil, read_fallback, normalize, prime)` registry in `core.processing.metric_readers` ([9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346)) - Shared `validate_mqtt_source_exists` (`_mqtt_validation.py`) deduplicates the MQTT-source existence check between the device and output-target routes ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **Automation engine** — per-rule-type bodies become `_handle_<kind>` methods, dispatch table built once at class-creation, unknown-type fallback logs instead of silently returning False ([98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d))
- **Effect renderer dispatch** — `@_effect_renderer("fire")` decorators + class-level `_RENDERERS` dict replace per-frame dict-rebuild + silent fire fallback ([97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c))
- **Output-target response builders** — `isinstance` ladder + silent fabricated-LED fallback replaced with `_TARGET_RESPONSE_BUILDERS` dict and a runtime `RuntimeError` for unknown subclasses ([2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb))
- **Versioned data migrations** — replaces a naked `blob.replace(...)` migration with `storage.data_migrations.MigrationRunner` backed by a `data_migrations` audit table and atomic transactions ([563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac))
#### Dedup / refactor #### Frontend
- **Edge-to-LED kernels** in `PixelMapper` + `AdvancedPixelMapper` deduped into a shared `core.capture.edge_interpolation` module ([5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db)) - Service-worker refresh for the new bundle ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **HA/Z2M `_swap_color_source`** unified behind a shared `light_target_helpers.swap_color_source` helper ([29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf))
- **Single-pixel `_average_color`** lifted out of 6 LED drivers into `core.devices.pixel_reduce.average_color` ([cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba))
- **Static → single rename** for the color-strip source kind. Storage keeps backward-compatible serialisation ([826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680))
- **Bindable types** extracted into `types/bindable.ts`; the wider `types.ts` god-module split is staged for a follow-up frontend sprint ([05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee))
- **WebSocket auth** — 11 `except Exception` sites around handshake replaced with a narrow `_WS_SEND_BENIGN_EXC` tuple; receive path adds explicit observability ([ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88))
- **Backend hardening bundle** — MQTT task tracking + drain resilience, credential encryption with auto-migration, devices watcher task tracking, WLED scheme inference at boundaries, streaming-upload caps, `asyncio.gather(return_exceptions=True)` on broadcast loops, WebSocket Origin allow-list, `/docs` auth-gate ([898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f))
- **Frontend infra** — inbound-event allowlist mirroring the server side, `closeIfPristine` adoption across editors, MiniSelect markup for filter pickers ([ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571))
- **PEP-604 union sweep** — `ruff --select UP007,UP045 --fix` converted ~1760 sites from `Optional[T]` / `Union[X, Y]` to `T | None` / `X | Y`. Hooks bumped to ruff v0.15.12 to recognise UP045 ([888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd))
- **Typed window globals** — 59 `(window as any).foo` sites across 19 feature modules switched to typed `window.foo` against `global-types.d.ts` ([0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172))
- **Processing magic numbers** lifted to named module constants so tests can monkeypatch them ([d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f))
- **`Database.ensure_open()`** — module-level singleton reopens cleanly across lifespan cycles, fixing 65 spurious `sqlite3.ProgrammingError` setup failures on Windows pytest aggregate runs ([f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25))
#### Tests #### Tests
- WLED URL scheme integration + IPv6 regression coverage ([907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf)) - New suites: graph routes + schema engine, snapshot routes, access-log middleware, `mqtt_source_id` device regressions, and the bounded-shutdown entrypoint. Full suite: **1614 passing** ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- Lifespan reopen invariants on `Database` ([f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25))
- Hundreds of new tests covering every registry / factory / migration introduced above
#### Tooling / docs
- `.vex.toml` makes vex the project's primary code-search backend with auto-update + semantic embeddings ([06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba))
- `REVIEW_TODO.md` captures audit items deliberately deferred; `TODO.md` records the architecture-audit remainder ([06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba), [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2))
- Locale + CLAUDE.md upkeep alongside the new features ([fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51), [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9), [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af), [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4))
--- ---
<details> <details>
<summary>All Commits (55)</summary> <summary>All Commits (1)</summary>
| Hash | Message | | Hash | Message | Author |
|------|---------| | ---- | ------- | ------ |
| [f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25) | fix(storage/database): reopen connection on lifespan restart | | [a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba) | feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers | alexei.dolgolyov |
| [f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9) | perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings |
| [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9) | docs(review-todo): check off items addressed in 2026-05-23 autonomous pass |
| [0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172) | refactor(types): migrate (window as any) statics to typed window globals |
| [888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd) | refactor(types): PEP-604 union sweep + UP007/UP045 enforcement |
| [ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88) | refactor(api/auth): narrow WS exception catches + observability log |
| [d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f) | refactor(processing): hot-path magic numbers -> named module constants |
| [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138) | feat(ui/icon-select): defence-in-depth XSS sanitiser on icon channel |
| [907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf) | test(url-scheme): WLED route-level integration + IPv6 regression |
| [0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43) | fix(devices): preserve existing URL on PATCH-without-url |
| [fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51) | docs: TODO + CLAUDE.md notes + locale keys for new features |
| [ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571) | chore(frontend-infra): inbound-event allowlist + storage/state touch-ups |
| [898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f) | chore(backend): MQTT/WLED/devices/capture/utils + api routes hardening |
| [45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2) | feat(update-service): SSRF-validated redirects + restart hardening |
| [826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680) | refactor(color-strip): rename static -> single + frontend follow-through |
| [737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72) | feat(value-sources): extend storage + schema + UI alongside new kinds |
| [3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8) | feat(automations): expand automation rules + UI + engine coverage |
| [f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30) | feat(modal): closeIfPristine save-guard + per-editor adoption |
| [9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd) | feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals |
| [d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800) | feat(http-endpoints): introduce HTTP endpoint output target stack |
| [06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba) | chore(tooling): vex semantic-search config + REVIEW_TODO backlog |
| [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2) | docs: capture architecture-audit remainder for follow-up sessions |
| [2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb) | refactor(output-targets): registry + coverage assertion for response builders |
| [c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb) | fix(value-source): preserve store contract for game_event + error precedence |
| [3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e) | refactor(value-source): per-type factories for create / update dispatch |
| [05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee) | refactor(types): extract bindable primitives into types/bindable.ts (H6 partial) |
| [9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346) | refactor(value-source): MetricSpec registry for SystemMetricsValueStream |
| [98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d) | refactor(automations): rule dispatch via class-level handler table |
| [5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db) | refactor(capture): lift duplicated edge-to-LED kernels into shared module |
| [97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c) | refactor(processing): replace inline effect dispatch with @_effect_renderer registry |
| [29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf) | refactor(processing): dedupe HA/Z2M _swap_color_source via shared helper |
| [563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac) | refactor(storage,processing): kind registries + versioned data migrations |
| [e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3) | fix(shutdown): survive PC restart with WAL fsync + Win32 session-end guard |
| [e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d) | fix(dashboard): stop showing perpetual MODIFIED for un-edited legacy layouts |
| [f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e) | fix(ui): repaint transport-bar uptime as soon as /health responds |
| [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af) | docs: record review-fix pass in TODO.md |
| [0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78) | fix(devices): address pre-merge review findings |
| [7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6) | fix(utils): commit url_scheme + net_classify dependencies |
| [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4) | docs: mark expand-device-support branch ready for merge |
| [cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba) | refactor(devices): extract _average_color to pixel_reduce |
| [426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a) | feat(devices): Nanoleaf OpenAPI target type + first pair-flow user |
| [2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680) | feat(devices): pairing-UX scaffold (Phase 2) |
| [31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a) | feat(devices): Open Pixel Control (OPC) target type |
| [887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d) | feat(devices): Govee LAN target type |
| [8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490) | feat(devices): LIFX LAN target type |
| [ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b) | feat(devices): WiZ Connected LAN target type |
| [4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005) | feat(devices): Yeelight LAN target type |
| [8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a) | feat(devices): standalone DDP target type |
| [337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c) | feat(color-strips): in-editor live preview for all viable source types |
| [530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c) | feat(mqtt): multi-broker MQTT + Zigbee2MQTT light target |
| [6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6) | perf(wled): cache per-frame max-pixel for brightness threshold |
| [ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81) | perf(processing): event-driven frame hand-off and scheduling fixes |
| [f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0) | perf(capture): vectorize hot paths and fix engine bugs |
| [ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60) | fix(ha-light): apply brightness_scale once and respect boost multipliers |
| [cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94) | feat(ui): expand card icon picker (44 -> 120 icons, +5 categories) |
</details> </details>
+249
View File
@@ -0,0 +1,249 @@
# Dashboard Reconciliation — Review Notes
*Captured 2026-05-26. Session focused on dashboard + perf-card flicker and per-poll re-rendering.*
*Updated 2026-05-27 — widened the audit beyond the two poll timers and found a **second driver** (server push) plus the **highest-blast-radius site** (`entity-events.ts`). Added §3.5, corrected the "out of scope" reasoning in §5, and confirmed the decision: **commit to the Lit migration**. Implementation deferred — this is still a planning doc, not a spec.*
This is a thinking-aloud document for whoever picks up reconciliation work next (likely me). It captures the bug class, what's already shipped, what's still latent, the decision ladder we walked through, and the recommendation we landed on. It is **not** a spec — treat any code shown as illustrative.
---
## 1. The bug class in one sentence
> Every place a data-driven render — a poll timer **or** a server-pushed `server:*` event — writes `el.innerHTML = ...`, the existing DOM is torn down — even when the new HTML equals the old — which restarts CSS animations, drops focus, skips transitions, and burns wasted DOM mutation cycles.
The symptom only becomes visually loud when the destroyed subtree contains a CSS keyframe animation (e.g. the pulsing `.perf-patches-empty-dot`). Everywhere else the cost is silent: lost transitions, broken focus, wasted layout work. The bug is **load-bearing in the architecture**, not in any single call site — that's why we keep coming back to it.
---
## 2. What landed in commit `f6486f9` (this session)
Tactical work — solves the worst cases, does not change the architecture.
### `server/src/ledgrab/static/js/features/dashboard.ts`
- Collapsed the two fast-path branches into one. Fast path runs when `structureUnchanged && !forceFullRender` regardless of `running.length`. Previously, **zero running targets meant every poll rebuilt the entire dashboard** even when nothing changed.
- `_lastSyncClockIds` no longer fingerprints `is_running` — pausing/resuming a clock no longer tears down every card. `_updateSyncClocksInPlace` already handles the toggle.
- `_updateAutomationsInPlace` now called from the unified fast path. Automation badges were silently going stale on the fast path.
- `_initFpsCharts` rewritten diff-based: only destroy charts for ids that left or whose canvas was detached by a DOM swap; only create for new ids; only fetch `/api/metrics/history` when there are genuinely new ids needing seed data.
- Sync-clock pause/resume/reset callers + `server:automation_state_changed` SSE handler now use `loadDashboard()` (no force) — `forceFullRender` is now actually load-bearing, meaning "settings changed, full rebuild required."
### `server/src/ledgrab/static/js/features/perf-charts.ts`
- `_renderChartSvg` no longer rewrites `innerHTML` per poll. The SVG skeleton (ref line + sys area/line + app line) is built once via `_ensureSparkNodes` and mutated thereafter. WeakMap cache (`_sparkNodeCache`) keyed by host element avoids the per-tick `querySelector` cost.
- Hidden cards (env-disabled GPU/Temp) skip render entirely.
- `_fetchPerformance` switched to `fetchWithAuth`.
- Hardcoded English strings replaced with `t()` calls. New keys: `perf.no_captures`, `perf.captures_count.{one,few,many,other}`, `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`, `perf.tip.now`, `perf.tip.ago` (en/ru/zh).
- Tooltip reads `dashboardPollInterval` per mousemove tick (was captured at bind time).
- Dead `<defs><linearGradient>` block removed.
- `updateTotalCaptureFpsActual` now delegates to `_paintCaptureFpsActualValue` — single code path.
- `updateActivePatches` / `updateDevices` skip the `innerHTML` write when content signature hasn't changed. This is the direct fix for the "READY TO LAUNCH flickers every update" report — the empty-state dot's CSS pulse no longer resets.
- Two missing semicolons in `_seedAggregateHistories` (ASI was saving us).
### Reviewer findings addressed (typescript-reviewer pass)
- **HIGH:** `_metricLabel` was looking up `dashboard.perf.${key}` but the FPS family uses `dashboard.perf.total_fps`, `total_capture_fps`, `total_capture_fps_actual`. Tooltip would have shouted `FPS` / `CAPTURE_FPS` / `CAPTURE_FPS_ACTUAL`. Fixed via explicit `METRIC_LABEL_KEYS` map.
- **HIGH:** `_ensureSparkNodes` silently coerced `null` children to non-null when the SVG existed but a child was missing. Hardened to validate all four children and rebuild if any are missing.
---
## 3. Hot spots still latent
These are the call sites where `innerHTML` is still written every poll. None are flickering today (no CSS animations on their inner elements), but every one is the same bug shape and will bite the next time someone adds a keyframe / transition / focus target inside.
### `perf-charts.ts`
| Line | Site | Fires per poll? | Notes |
|------|------|-----------------|-------|
| 462 | `updateActivePatches``listEl.innerHTML` | yes | guarded by signature compare (✓) |
| 493 | `updateTotalFps``valEl.innerHTML` | yes | FPS value, no inner animation |
| 526 | `updateTotalCaptureFps``valEl.innerHTML` | yes | same |
| 638 | `_paintNetworkValue``valEl.innerHTML` | yes | bytes/s value |
| 655 | `_paintDeviceLatencyValue``valEl.innerHTML` (no-devices hint) | yes | hint span |
| 657 | `_paintDeviceLatencyValue``valEl.innerHTML` (offline hint) | yes | hint span |
| 660 | `_paintDeviceLatencyValue``valEl.innerHTML` (ms value) | yes | value |
| 676 | `_paintSendTimingValue``valEl.innerHTML` (idle hint) | yes | hint span |
| 679 | `_paintSendTimingValue``valEl.innerHTML` (ms value) | yes | value |
| 738 | `_paintErrorsValue``valEl.innerHTML` | yes | rate value |
| 806 | `updateDevices``dotsEl.innerHTML` | yes | guarded by signature compare (✓) |
| 1086 | `_renderValuePair``mainEl.innerHTML = appVal` | yes | dual sys/app value |
| 1088 | `_renderValuePair``mainEl.innerHTML = sysVal` | yes | dual sys/app value |
| 1094 | `_renderValuePair``tagEl.innerHTML` (App tag) | mode='both' only | App tag in `both` mode |
| 1181 | `_applyPerfDataToDom` temp hint | only when cpu_temp_hint_key changes | rare |
| 1449 | `_paintFpsValue` | seed only | once per init |
| 1456 | `_paintCaptureFpsValue` | seed only | once per init |
| 1463 | `_paintCaptureFpsActualValue` (no-captures hint) | yes via live updater | now goes through painter |
| 1469 | `_paintCaptureFpsActualValue` (value) | yes via live updater | same |
| 1499 | `_paintErrorsValue` (duplicate of 738) | seed only | once per init |
| 1823 | tooltip `tip.innerHTML` | per mousemove | rate-limited by hover only |
### `dashboard.ts`
| Line | Site | Fires per poll? | Notes |
|------|------|-----------------|-------|
| 275 | `_updateRunningMetrics``fpsEl.innerHTML` | per running target | live FPS pill — visible churn |
| 293 | `_updateRunningMetrics``labelEl.innerHTML` (errors label) | per running target | rebuilt each poll |
| 340 | `_updateAutomationsInPlace``btn.innerHTML` | only on enable/disable change | low frequency |
| 366 | `_updateSyncClocksInPlace``btn.innerHTML` | per poll for every clock | wasteful |
| 975 | `loadDashboard` first-load → `container.innerHTML` | once per init | fine |
| 989 | `loadDashboard` slow path → `dynamic.innerHTML = dynamicHtml` | only when slow path fires | the **big** swap, scoped already |
| 1010 | `loadDashboard` error path | rare | fine |
| 1416 | `subscribeDashboardLayout` clear | rare | fine |
### What this list tells us
- The remaining innerHTML writes are **per-cell value updates** that paint formatted spans (`{value}<span class="perf-fps-unit">fps</span>`). Each rewrite destroys two text nodes + a span every poll across ~10 cells. Not flickering today; will flicker the moment anyone adds an animation to `.perf-fps-unit` or `.perf-fps-ceiling`.
- The pattern can be killed without architectural change by splitting these into a stable structure (number text node + static unit span) and only updating `textContent` of the number. That's what L3 / Lit would force naturally.
---
## 3.5 Beyond dashboard/perf — push-driven reconciliation
*Added 2026-05-27. The §3 audit was scoped to the two poll timers we were debugging. Widening the `\.innerHTML\s*=` search showed the bug class has a **second driver** and lives outside dashboard/perf too.*
### Two drivers, not one
The teardown is triggered by anything that re-renders **without user intent**:
- **Poll timers** (`setInterval`) — what §2/§3 covered (`dashboard.ts` `_uptimeTimer` + main refresh, `perf-charts.ts` `_pollTimer`).
- **Server-pushed `server:*` events** — `core/events-ws.ts` turns each WS message into a `server:*` CustomEvent; feature modules listen and re-render through the *same* `innerHTML` paths.
So the one-line bug class in §1 reads "poll- **or** push-driven," not just poll.
### Genuinely-affected sites outside dashboard/perf
| Site | Driver | Shape | Notes |
| ---- | ------ | ----- | ----- |
| `core/entity-events.ts` `_invalidateAndReload` | push (`server:entity_changed`, `server:device_health_changed`) | full-**tab** rebuild via `loadTargetsTab` / `loadPictureSources` / `loadAutomations` / `loadIntegrations` | **highest blast radius.** A single pushed entity change tears down and rebuilds an entire tab — losing scroll, focus, open inline editors, restarting card-enter animations. |
| `features/game-integration.ts` event feed (`_eventMonitorTimer`) | poll (2 s) | `feed.innerHTML = events.slice(0,20).map(...)` | full 20-item list rebuild every 2 s while the panel is open. |
| `features/game-integration.ts` connection test (`_connectionTestTimer`) | poll | `panel.innerHTML = …` per tick | transient, low frequency. |
`entity-events.ts` already has the **L1 floor applied by hand**: a 600 ms debounce plus a diff check (`oldData === newData`, then length + `id` + `updated_at` compare) that skips the reload when nothing changed. That kills the *no-op* case — but a **real** change still does the full-tab teardown. This is exactly the §4-L1 limitation ("still tears down when content *does* differ"), live across the whole app.
### Counter-examples that already do it right
Two poll loops never flicker because they mutate `textContent` on a **stable structure** instead of rewriting `innerHTML`:
- `core/api.ts` `loadServerInfo` (connection-check poll) — `versionEl.textContent` / `statusEl.textContent`.
- `features/color-strips/test.ts` FPS sampler (1 s) — `valueEl.textContent` / `avgEl.textContent`.
These are live proof that "stable structure + mutate text node" is the fix — i.e. what L3 / Lit force by construction.
### What this changes about the plan
The §4 ladder was reasoned entirely around **per-cell** rendering, because that was the visible flicker. The push-driven finding surfaces a second, qualitatively different problem:
- **Problem A — cell value churn:** every poll, one value span. Loud only with animations. *Mostly fixed in `f6486f9`.* → wants `setText` / skip-if-unchanged.
- **Problem B — list/tab teardown:** on change/push, an entire list or tab. Loses scroll/focus/open editors. *Unaddressed.* `entity-events.ts` and the game feed are Problem B. → wants **keyed list reconciliation**.
Problem B is a **list-level** concern, not a cell-level one. In Lit terms it maps to a keyed `repeat()` directive over the tab/list body — the dashboard-card work in Phase 2 already needs this, but `entity-events.ts` needs it for tabs that §5 used to list as "out of scope." This does **not** change the chosen direction (Lit); it adds `entity-events.ts` as a first-class, high-priority target.
---
## 4. Decision ladder
Walked through with the user 2026-05-26. Captured here so we don't re-litigate.
### L1 — drop-in `setInnerHtmlIfChanged` helper
- **Shape:** `WeakMap<Element, string>` cache; replace every `el.innerHTML = x` with `setInnerHtmlIfChanged(el, x)`.
- **Wins:** stops the no-change rewrites globally; zero behavior risk; ~30 call-site changes.
- **Misses:** still tears down DOM when content *does* differ (e.g. FPS row values change every tick); doesn't preserve focus/transition state inside a list.
- **Verdict:** floor, not ceiling. Worth doing for cells that don't get migrated to L3/Lit.
### L2 — lint guard
- **Shape:** pre-commit script greps `\.innerHTML\s*=` in `static/js/` outside an allowlist, fails the commit.
- **Wins:** keeps the discipline; cheap.
- **Misses:** only useful as a pair with L1+; bare guard with no helper makes contributors angry.
- **Verdict:** pair with whatever helper we land on.
### L3 — hand-rolled cell-component pattern
- **Shape:** `defineCell({ html, refs, mount, update, unmount })` + `reconcileList(host, items, binding)` + `setText/setClass/setAttr` mutators. ~150300 lines of runtime.
- **Wins:** correct by construction; no dependencies; explicit about what mutates; composes with existing customize panel / color picker.
- **Misses:** we own the abstraction — it grows over time as we need transitions, async data, focus, devtools, error boundaries. Death by a thousand features.
- **Verdict:** second-best. Strong contender if zero-deps is a hard constraint.
### Lit migration of polling modules — **recommended**
- **Shape:** convert each perf cell + each dashboard card cell to a Lit web component. Use `html\`<span>${value}</span>\`` tagged-template + targeted diff. ~5KB gzip added to bundle, no new build step (esbuild handles it).
- **Wins:** solves the bug class by design; maintained by Google + community; web-components-based so no framework lock-in; composes with vanilla DOM trivially; mental model is close to current template-string idiom; non-polling code can stay vanilla forever.
- **Misses:** introduces a dependency; contributors learn one more thing; rare edge cases (`@html`-equivalent exists and reintroduces the bug if misused).
- **Verdict:** best ceiling-to-cost ratio for a small team. Recommended.
### Full framework rewrite (React / Vue / Solid)
- **Verdict:** overkill. The bug class lives in polling paths; the rest of the app is fine. Spending the migration budget on rebuilding IconSelect / EntitySelect / modals / customize panel / graph editor — none of which are broken — is a bad trade.
---
## 5. Recommendation
**Lit for the polling-heavy modules.**
Migration plan:
### Phase 0 — spike (2-hour time-box)
- Convert `patches` cell to a Lit component, end to end.
- Verify it plays nicely with: color picker integration, customize panel layout reorder, `rerenderPerfGrid` reconciliation, `setPerfMode` toggle, hidden-by-env state, the spark tooltip handler.
- If any of those break in an unfixable way → pivot to L3.
- If they work → commit to the migration.
### Phase 1 — perf-charts cells
1. `patches` (already spiked)
2. `devices`
3. `fps` / `capture_fps` / `capture_fps_actual` (share a sparkline base class)
4. `cpu` / `ram` / `gpu` / `temp` (share `_sparkCardHtml` template family)
5. `network` / `device_latency` / `send_timing` / `errors`
Each is its own PR, dashboard stays working at every step. `renderPerfSection` becomes a registry of Lit components; `rerenderPerfGrid` becomes "reorder existing elements in the grid" (which it mostly already does).
### Phase 2 — dashboard card cells
6. Output target cards (running variant — biggest payoff, has live FPS + uptime + errors)
7. Output target cards (stopped variant)
8. Sync clock cards
9. Automation cards
10. Integration (HA / MQTT) cards
These get bigger wins from the migration because they have nested mutable state (FPS pill, errors cell, health dot, action button) that's currently rebuilt per poll via the `_updateRunningMetrics` path.
### Highest-impact: `entity-events.ts` tab reconciliation (sequence early)
`entity-events.ts` (§3.5) is the single highest-blast-radius site and is **not** on the dashboard — it re-renders the Targets / Integrations / Automations tabs on server push. Whether or not those tabs' cells become Lit components, the loader path (`loadTargetsTab` / `loadIntegrations` / `loadAutomations`) should switch from a full `innerHTML` rebuild to a **keyed list reconcile** (a Lit `repeat()` over the tab body). This preserves scroll / focus / open inline editors across pushes. If the goal is "biggest UX win first" rather than "lowest-risk first," sequence this ahead of Phase 2.
### Phase 3 — stopgap helper for the rest
Add `setInnerHtmlIfChanged` and apply to any remaining vanilla polling sites we don't plan to migrate. Add the L2 lint guard at this point — by now everything that polls is either Lit-managed or uses the helper.
### Out of scope (deliberately) — with one correction (2026-05-27)
- Targets tab, automations editor, integrations, scene presets — these render on-demand, **but they are ALSO re-rendered on server push** via `entity-events.ts` (see §3.5). The original claim that "the bug class doesn't bite them" was **wrong**: a pushed `server:entity_changed` does a full-tab `innerHTML` teardown. The *editor / on-demand views* can stay vanilla, but the **list/tab render that entity-events triggers needs reconciliation** (a keyed list diff) regardless of whether those cells become Lit components. Treat the entity-events reload path as **in-scope** — it is the highest-blast-radius Problem B site.
- Color strips editor, graph editor, settings — genuinely on-demand, no push re-render path, stay vanilla.
- Transport bar cells (CPU/Mem chip in the top bar) — read from the same perf payload, can be migrated opportunistically but not urgent.
---
## 6. Open questions to settle before committing
These came up during the discussion and weren't resolved:
1. **Bundle-size budget.** Is +5KB acceptable? Current bundle is 2.7MB so this is noise — but worth confirming there isn't a strict cap (e.g. for slow networks / Android Chaquopy embed).
2. **Contributor model.** If the project will grow to multiple contributors, Lit's smaller community vs React's is a recruiting tradeoff. Currently solo-ish, so probably moot.
3. **Android TV target.** Chaquopy embed serves the same bundle. Lit works fine in any modern browser — Android TV WebView is Chromium-based. Should be a no-op but verify in Phase 0 spike.
4. **Long-term framework intent.** If there's a chance we ever migrate to React/Vue/Solid for the rest of the app, doing Lit now is *not* lock-in (web components are standard), but it does add a second mental model. Probably fine; just naming the tradeoff.
5. **Customize panel.** The drag-reorder code in `dashboard-customize.ts` mutates `.dashboard-section` DOM directly. Lit components reorder cleanly via `moveBefore` / `insertBefore` since they're just elements, but the dnd library needs to treat them as opaque drag handles. Phase 0 spike should confirm.
---
## 7. Pointers
- Source files most relevant:
- `server/src/ledgrab/static/js/features/dashboard.ts`
- `server/src/ledgrab/static/js/features/perf-charts.ts`
- `server/src/ledgrab/static/js/features/dashboard-layout.ts` (cell ordering + visibility)
- `server/src/ledgrab/static/js/features/dashboard-customize.ts` (drag-reorder UI)
- `server/src/ledgrab/static/js/core/card-modes.ts` (mode toggle that hangs off section headers)
- `server/src/ledgrab/static/js/core/entity-events.ts` (push-driven tab reloads — §3.5, highest blast radius)
- `server/src/ledgrab/static/js/core/events-ws.ts` (WS → `server:*` CustomEvent dispatch)
- `server/src/ledgrab/static/js/features/game-integration.ts` (2 s event-feed list rebuild — §3.5)
- Most recent reconciliation commit: `f6486f9`.
- Related skill files in `~/.claude/skills/`: `frontend-patterns`, `documentation-lookup` (for Lit docs via Context7).
- Locale convention: `perf.*` for cross-card primitives, `dashboard.perf.*` for cell titles.
---
## 8. If this doc gets stale
If you read this and the perf cells are already Lit components — delete this file. If you read this and there's a new flicker / focus / transition bug nobody can explain — search for `\.innerHTML\s*=` in `static/js/features/` **and `static/js/core/`** (`entity-events` lives in core) and you've probably found it. For *state loss on a server event* (scroll jump, focus drop, an inline editor closing itself), look at the `server:*` listeners in `core/entity-events.ts` first.
+18 -5
View File
@@ -201,9 +201,19 @@ caller off the legacy path, then delete it.
- [x] Field on `device_config.MQTTConfig` - [x] Field on `device_config.MQTTConfig`
- [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()` - [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()`
- [x] Provider threads `mqtt_manager` via `ProviderDeps` - [x] Provider threads `mqtt_manager` via `ProviderDeps`
- [ ] Device editor: MQTT source picker shown for `device_type=mqtt` *(UI still - [x] Device editor: MQTT source picker shown for `device_type=mqtt`. Turned
pending — backend accepts the field, but the device-create form doesn't out the API layer was *also* missing it (the TODO's "backend accepts the
expose it yet)* field" was wrong — `mqtt_source_id` lived in `device_store` +
`device_config.MQTTConfig` but was dropped by `DeviceCreate/Update/Response`
and the routes). Added: schema fields + route threading + referenced-source
validation (`_validate_mqtt_source_exists`, mirrors output_targets) +
`except HTTPException: raise` guard in `update_device` (it was masking its
own 4xx as 500). Frontend: broker `EntitySelect` (reusing `mqttSourcesCache`)
in both the add-device (`device-discovery.ts`) and settings
(`devices.ts`) modals — shown for `device_type=mqtt`, wired into
load/save/validate/dirty-check/clone. Empty = "first available broker".
4 regression tests in `test_devices_routes.py::TestMqttSourceId`; full
suite 1567 passing; en/ru/zh keys added.
### Phase 5 — `AutomationEngine` ### Phase 5 — `AutomationEngine`
@@ -213,8 +223,11 @@ caller off the legacy path, then delete it.
### Phase 6 — `api/routes/system.py` ### Phase 6 — `api/routes/system.py`
- [x] Replace integration status with `mqtt_manager.get_all_sources_status()` - [x] Replace integration status with `mqtt_manager.get_all_sources_status()`
- [ ] Update frontend dashboard payload (MQTT widget now expects a list of - [x] Update frontend dashboard payload (MQTT widget now expects a list of
sources instead of a single `enabled`/`connected` pair — surface in UI) sources instead of a single `enabled`/`connected` pair — surface in UI).
Done: `dashboard.ts` `_renderMQTTIntegrationCard` renders one card per
`mqttStatus.connections` entry; `_updateIntegrationsInPlace` iterates the
list.
### Phase 7 — Startup migration ### Phase 7 — Startup migration
+1 -1
View File
@@ -41,7 +41,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before — // in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install. // sideload updates silently refused to install.
versionCode = ledgrabVersionCode versionCode = ledgrabVersionCode
versionName = "0.7.0" versionName = "0.8.1"
// ABI selection. Detect armeabi-v7a wheel presence and opt the // ABI selection. Detect armeabi-v7a wheel presence and opt the
// ABI in only when the matching pydantic-core wheel is on disk — // ABI in only when the matching pydantic-core wheel is on disk —
+327
View File
@@ -0,0 +1,327 @@
"""Generate LedGrab app icon assets.
Concept: "Spectrum Aperture" — a rounded-square frame (the screen/display)
traced by a continuous RGB color-wheel stroke (the bias-light LED strip),
on a near-black canvas with a soft chromatic bloom behind it.
Outputs:
server/src/ledgrab/static/icons/icon-512.png (standard, opaque vignette bg)
server/src/ledgrab/static/icons/icon-192.png (downscale of 512)
server/src/ledgrab/static/icons/icon-512-maskable.png (safe-area padded, opaque)
server/src/ledgrab/static/icons/icon-tray.png (256, transparent bg, frame + glow)
server/src/ledgrab/static/icons/icon.ico (16/24/32/48/64/128/256)
Run from repo root:
py -3.13 build/generate_icon.py
"""
from __future__ import annotations
import colorsys
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
# ── Tunables ────────────────────────────────────────────────────────────
SUPERSAMPLE = 4 # render at 4x and downsample for crispness
BASE = 1024 # logical canvas size
HQ = BASE * SUPERSAMPLE # render canvas
BG_TOP = (12, 14, 22) # near-black, faint cool tint
BG_BOTTOM = (6, 7, 12) # darker at edges (vignette feel)
FRAME_INSET = 0.18 # margin from canvas edge to frame (fraction)
FRAME_RADIUS = 0.22 # corner radius (fraction of frame side)
FRAME_STROKE = 0.085 # stroke width (fraction of canvas)
BLOOM_OPACITY = 0.62 # outer bloom strength (01)
INNER_GLOW_OPACITY = 0.38 # inner chromatic reflection strength
# Hue rotation offset so red sits at the top
HUE_OFFSET = -90.0 # degrees (negative = counter-clockwise shift)
def lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def hue_to_rgb(hue_deg: float) -> tuple[int, int, int]:
"""Bright, slightly desaturated spectral color (LED-like)."""
h = (hue_deg % 360) / 360.0
r, g, b = colorsys.hls_to_rgb(h, 0.58, 0.92)
return int(r * 255), int(g * 255), int(b * 255)
def vignette_background(size: int) -> Image.Image:
"""Dark canvas with a soft radial vignette + faint scanline noise."""
img = Image.new("RGB", (size, size), BG_TOP)
px = img.load()
cx, cy = size / 2, size / 2
max_r = math.hypot(cx, cy)
for y in range(size):
for x in range(size):
d = math.hypot(x - cx, y - cy) / max_r
t = min(1.0, d**1.6)
px[x, y] = (
int(lerp(BG_TOP[0], BG_BOTTOM[0], t)),
int(lerp(BG_TOP[1], BG_BOTTOM[1], t)),
int(lerp(BG_TOP[2], BG_BOTTOM[2], t)),
)
return img
def draw_chromatic_bloom(size: int) -> Image.Image:
"""Soft, large chromatic glow behind the frame — the bias-light effect."""
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
cx, cy = size / 2, size / 2
radius = size * 0.36
blob_r = int(size * 0.30)
n_blobs = 24
for i in range(n_blobs):
a = i / n_blobs * 360.0
bx = cx + math.cos(math.radians(a - 90)) * radius
by = cy + math.sin(math.radians(a - 90)) * radius
r, g, b = hue_to_rgb(a + HUE_OFFSET)
alpha = int(255 * BLOOM_OPACITY * 0.55)
draw.ellipse(
(bx - blob_r, by - blob_r, bx + blob_r, by + blob_r),
fill=(r, g, b, alpha),
)
# Heavy blur → continuous, dreamy halo
layer = layer.filter(ImageFilter.GaussianBlur(radius=size * 0.10))
return layer
def rounded_rect_mask(size: int, inset: int, radius: int, stroke: int) -> Image.Image:
"""L-mode mask of a rounded-rect ring (the frame stroke region)."""
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
box_outer = (inset, inset, size - inset, size - inset)
box_inner = (
inset + stroke,
inset + stroke,
size - inset - stroke,
size - inset - stroke,
)
r_outer = radius
r_inner = max(0, radius - stroke)
draw.rounded_rectangle(box_outer, radius=r_outer, fill=255)
draw.rounded_rectangle(box_inner, radius=r_inner, fill=0)
return mask
def draw_spectrum_frame(size: int) -> Image.Image:
"""Draw the rounded-square frame stroke filled with a hue-rotation gradient.
Strategy: paint a full-canvas angular hue gradient (centered), then
clip it with the rounded-ring mask. This guarantees a continuous,
seam-free color flow around the entire frame.
"""
cx, cy = size / 2, size / 2
gradient = Image.new("RGB", (size, size), (0, 0, 0))
gpx = gradient.load()
for y in range(size):
dy = y - cy
for x in range(size):
dx = x - cx
ang = math.degrees(math.atan2(dy, dx)) + 90.0 # 0° = top
r, g, b = hue_to_rgb(ang + HUE_OFFSET)
gpx[x, y] = (r, g, b)
inset = int(size * FRAME_INSET)
frame_side = size - 2 * inset
stroke = int(size * FRAME_STROKE)
radius = int(frame_side * FRAME_RADIUS)
mask = rounded_rect_mask(size, inset, radius, stroke)
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
out.paste(gradient, (0, 0), mask)
return out
def draw_inner_screen(size: int) -> Image.Image:
"""Subtle dark rounded square inside the frame, with faint chromatic
inner reflection along the edges — like a screen catching ambient light."""
inset = int(size * FRAME_INSET)
stroke = int(size * FRAME_STROKE)
frame_side = size - 2 * inset
radius = int(frame_side * FRAME_RADIUS)
pad = int(stroke * 0.35)
box = (
inset + stroke + pad,
inset + stroke + pad,
size - inset - stroke - pad,
size - inset - stroke - pad,
)
r_inner = max(0, radius - stroke - pad)
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
# Dark fill, very slight cool tint
draw.rounded_rectangle(box, radius=r_inner, fill=(10, 12, 18, 255))
# Inner chromatic glow: same spectrum, very soft, clipped to the screen
bloom = draw_chromatic_bloom(size)
screen_mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(screen_mask).rounded_rectangle(box, radius=r_inner, fill=255)
bloom_alpha = bloom.split()[-1].point(lambda v: int(v * INNER_GLOW_OPACITY))
bloom.putalpha(bloom_alpha)
masked_bloom = Image.new("RGBA", (size, size), (0, 0, 0, 0))
masked_bloom.paste(bloom, (0, 0), screen_mask)
layer.alpha_composite(masked_bloom)
# Faint highlight glint top-left
glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
gdraw = ImageDraw.Draw(glint)
glint_box = (
box[0] + int(frame_side * 0.04),
box[1] + int(frame_side * 0.04),
box[0] + int(frame_side * 0.42),
box[1] + int(frame_side * 0.18),
)
gdraw.rounded_rectangle(glint_box, radius=int(frame_side * 0.05), fill=(255, 255, 255, 22))
glint = glint.filter(ImageFilter.GaussianBlur(radius=size * 0.012))
masked_glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
masked_glint.paste(glint, (0, 0), screen_mask)
layer.alpha_composite(masked_glint)
return layer
def add_outer_frame_glow(frame_rgba: Image.Image) -> Image.Image:
"""Take the spectrum frame and produce a blurred, brightened copy for glow."""
glow = frame_rgba.copy()
# Slightly inflate brightness for glow
r, g, b, a = glow.split()
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.85)))))
glow = glow.filter(ImageFilter.GaussianBlur(radius=glow.width * 0.025))
return glow
def render_tray(size: int) -> Image.Image:
"""Render a tray-optimised icon: transparent background, bolder frame,
tight outer glow. Designed to read clearly at 1632 px on top of any
taskbar color."""
hq = size * SUPERSAMPLE
# Pull the frame inward a touch and beef up the stroke so it reads at 16 px.
global FRAME_INSET, FRAME_STROKE
saved_inset, saved_stroke = FRAME_INSET, FRAME_STROKE
FRAME_INSET = 0.13
FRAME_STROKE = 0.115
try:
frame = draw_spectrum_frame(hq)
finally:
FRAME_INSET, FRAME_STROKE = saved_inset, saved_stroke
# Tight, bright glow that doesn't bleed past the tray cell.
glow = frame.copy()
r, g, b, a = glow.split()
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.95)))))
glow = glow.filter(ImageFilter.GaussianBlur(radius=hq * 0.012))
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
canvas.alpha_composite(glow)
canvas.alpha_composite(frame)
return canvas.resize((size, size), Image.LANCZOS)
def render(size: int, *, maskable: bool = False) -> Image.Image:
"""Render the full icon at the given size."""
hq = size * SUPERSAMPLE
if maskable:
# Maskable: pad inward so the entire icon survives a circular crop.
# We render the standard composition at 80% of canvas size, centered.
bg = Image.new("RGB", (hq, hq), BG_BOTTOM).convert("RGBA")
bg.paste(vignette_background(hq), (0, 0))
inner = render(size, maskable=False).resize((int(hq * 0.78), int(hq * 0.78)), Image.LANCZOS)
# Strip the bg from the inner render: composite the spectrum
# parts on top of our maskable background.
ox = (hq - inner.width) // 2
oy = (hq - inner.height) // 2
bg.alpha_composite(inner, (ox, oy))
return bg.resize((size, size), Image.LANCZOS)
bg = vignette_background(hq).convert("RGBA")
bloom = draw_chromatic_bloom(hq)
frame = draw_spectrum_frame(hq)
frame_glow = add_outer_frame_glow(frame)
inner_screen = draw_inner_screen(hq)
# Composite order: bg → bloom → frame_glow → inner_screen → frame
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
canvas.alpha_composite(bg)
canvas.alpha_composite(bloom)
canvas.alpha_composite(frame_glow)
canvas.alpha_composite(inner_screen)
canvas.alpha_composite(frame)
return canvas.resize((size, size), Image.LANCZOS)
def main() -> None:
repo_root = Path(__file__).resolve().parent.parent
targets = [
repo_root / "server" / "src" / "ledgrab" / "static" / "icons",
repo_root
/ "android"
/ "app"
/ "build"
/ "python"
/ "sources"
/ "debug"
/ "ledgrab"
/ "static"
/ "icons",
]
print("Rendering 1024 master...")
master = render(1024, maskable=False)
print("Rendering maskable 1024 master...")
maskable_master = render(1024, maskable=True)
print("Rendering tray 512 master (transparent bg)...")
tray_master = render_tray(512)
for icons_dir in targets:
if not icons_dir.exists():
print(f" skip (missing): {icons_dir}")
continue
out_512 = icons_dir / "icon-512.png"
out_192 = icons_dir / "icon-192.png"
out_mask = icons_dir / "icon-512-maskable.png"
out_tray = icons_dir / "icon-tray.png"
out_ico = icons_dir / "icon.ico"
master.resize((512, 512), Image.LANCZOS).save(out_512, "PNG", optimize=True)
master.resize((192, 192), Image.LANCZOS).save(out_192, "PNG", optimize=True)
maskable_master.resize((512, 512), Image.LANCZOS).save(out_mask, "PNG", optimize=True)
tray_master.save(out_tray, "PNG", optimize=True)
# Pre-resize each frame from the 1024 master for maximum crispness.
# Pass them via the `sizes` arg so Pillow embeds every variant.
ico_sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
# Use the tray (transparent-bg) variant for ICO frames so the file/
# taskbar icon doesn't show a dark tile against light backgrounds.
ico_source = tray_master.resize((256, 256), Image.LANCZOS)
ico_source.save(out_ico, format="ICO", sizes=ico_sizes)
print(f" wrote: {icons_dir}")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "ledgrab" name = "ledgrab"
version = "0.7.0" version = "0.8.1"
description = "Ambient lighting system that captures screen content and drives LED strips in real time" description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [ authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"} {name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+1 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
# In dev (running from source without `pip install -e .`) and on Android # In dev (running from source without `pip install -e .`) and on Android
# (Chaquopy embeds the source directly with no dist-info), we additionally # (Chaquopy embeds the source directly with no dist-info), we additionally
# read pyproject.toml so the version is always correct without manual sync. # read pyproject.toml so the version is always correct without manual sync.
_FALLBACK_VERSION = "0.7.0" _FALLBACK_VERSION = "0.8.1"
def _read_pyproject_version() -> str | None: def _read_pyproject_version() -> str | None:
+26 -10
View File
@@ -39,8 +39,9 @@ _fix_embedded_tcl_paths()
import uvicorn # noqa: E402 import uvicorn # noqa: E402
from ledgrab.config import get_config # noqa: E402 from ledgrab.config import Config, get_config # noqa: E402
from ledgrab.server_ref import set_server, set_tray # noqa: E402 from ledgrab.server_ref import set_server, set_tray # noqa: E402
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT # noqa: E402
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402 from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
from ledgrab.utils import setup_logging, get_logger # noqa: E402 from ledgrab.utils import setup_logging, get_logger # noqa: E402
from ledgrab.utils.platform import is_windows # noqa: E402 from ledgrab.utils.platform import is_windows # noqa: E402
@@ -49,7 +50,8 @@ from ledgrab.utils.win_shutdown import WindowsShutdownGuard # noqa: E402
setup_logging() setup_logging()
logger = get_logger(__name__) logger = get_logger(__name__)
_ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png" _ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-tray.png"
_ICON_FALLBACK_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png"
def _run_server(server: uvicorn.Server) -> None: def _run_server(server: uvicorn.Server) -> None:
@@ -107,17 +109,28 @@ def _check_port(host: str, port: int) -> None:
sys.exit(1) sys.exit(1)
def main() -> None: def _build_server(config: Config) -> uvicorn.Server:
config = get_config() """Construct the uvicorn Server with a bounded graceful-shutdown timeout.
_check_port(config.server.host, config.server.port)
Extracted so the graceful-shutdown bound is unit-testable — leaving it
unset (the uvicorn default of ``None``) is the regression that strands
LED targets and prevents the process from exiting.
"""
uv_config = uvicorn.Config( uv_config = uvicorn.Config(
"ledgrab.main:app", "ledgrab.main:app",
host=config.server.host, host=config.server.host,
port=config.server.port, port=config.server.port,
log_level=config.server.log_level.lower(), log_level=config.server.log_level.lower(),
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
) )
server = uvicorn.Server(uv_config) return uvicorn.Server(uv_config)
def main() -> None:
config = get_config()
_check_port(config.server.host, config.server.port)
server = _build_server(config)
set_server(server) set_server(server)
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals # Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
@@ -154,8 +167,9 @@ def main() -> None:
).start() ).start()
# Tray on main thread (blocking) # Tray on main thread (blocking)
tray_icon = _ICON_PATH if _ICON_PATH.exists() else _ICON_FALLBACK_PATH
tray = TrayManager( tray = TrayManager(
icon_path=_ICON_PATH, icon_path=tray_icon,
port=config.server.port, port=config.server.port,
on_exit=lambda: _request_shutdown(server), on_exit=lambda: _request_shutdown(server),
) )
@@ -163,9 +177,11 @@ def main() -> None:
tray.run() tray.run()
# Tray exited — wait for server to finish its graceful shutdown. # Tray exited — wait for server to finish its graceful shutdown.
# Use a longer join than the lifespan's own ~18 s budget so we don't # Budget: the graceful-shutdown wait (GRACEFUL_SHUTDOWN_TIMEOUT) runs
# cut the DB checkpoint short on a slow disk. # first, then the lifespan's own ~16 s shutdown (target restore + DB
server_thread.join(timeout=20) # checkpoint). Join longer than their sum so a slow disk doesn't get
# the DB checkpoint cut short.
server_thread.join(timeout=25)
if guard is not None: if guard is not None:
guard.stop() guard.stop()
else: else:
+5
View File
@@ -84,6 +84,8 @@ def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) ->
"LEDGRAB_AUTH__API_KEYS." "LEDGRAB_AUTH__API_KEYS."
) )
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
uv_config = uvicorn.Config( uv_config = uvicorn.Config(
"ledgrab.main:app", "ledgrab.main:app",
host=config.server.host, host=config.server.host,
@@ -91,6 +93,9 @@ def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) ->
log_level=config.server.log_level.lower(), log_level=config.server.log_level.lower(),
# No uvloop/httptools on Android — use pure-Python asyncio # No uvloop/httptools on Android — use pure-Python asyncio
loop="asyncio", loop="asyncio",
# Bound the graceful-shutdown wait so stop_server() can't hang forever
# on a lingering WebView events WebSocket — see shutdown_state for why.
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
) )
global _server, _loop global _server, _loop
+4
View File
@@ -33,6 +33,8 @@ from .routes.audio_processing_templates import router as audio_processing_templa
from .routes.audio_filters import router as audio_filters_router from .routes.audio_filters import router as audio_filters_router
from .routes.pattern_templates import router as pattern_templates_router from .routes.pattern_templates import router as pattern_templates_router
from .routes.preferences import router as preferences_router from .routes.preferences import router as preferences_router
from .routes.snapshot import router as snapshot_router
from .routes.graph import router as graph_router
router = APIRouter() router = APIRouter()
router.include_router(system_router) router.include_router(system_router)
@@ -66,5 +68,7 @@ router.include_router(audio_processing_templates_router)
router.include_router(audio_filters_router) router.include_router(audio_filters_router)
router.include_router(pattern_templates_router) router.include_router(pattern_templates_router)
router.include_router(preferences_router) router.include_router(preferences_router)
router.include_router(snapshot_router)
router.include_router(graph_router)
__all__ = ["router"] __all__ = ["router"]
+4
View File
@@ -80,6 +80,7 @@ def verify_api_key(
if not config.auth.api_keys: if not config.auth.api_keys:
# No keys configured — allow loopback only. # No keys configured — allow loopback only.
if _is_loopback(client_host): if _is_loopback(client_host):
request.state.auth_label = "anonymous"
return "anonymous" return "anonymous"
# Allow caller to authenticate explicitly even without configured keys? # Allow caller to authenticate explicitly even without configured keys?
# No — there are no keys to compare against. Reject. # No — there are no keys to compare against. Reject.
@@ -123,6 +124,9 @@ def verify_api_key(
# Log successful authentication # Log successful authentication
logger.debug(f"Authenticated as: {authenticated_as}") logger.debug(f"Authenticated as: {authenticated_as}")
# Stash the friendly label so the access-log middleware can attribute the
# request to a client without re-running the token comparison.
request.state.auth_label = authenticated_as
return authenticated_as return authenticated_as
+501
View File
@@ -0,0 +1,501 @@
"""Authoritative wiring-graph schema and topology engine.
This module is the single source of truth for **which reference fields connect
which entity kinds**. The frontend graph editor historically hard-coded the same
information in two places (``graph-connections.ts`` ``CONNECTION_MAP`` and
``graph-layout.ts`` ``buildGraph``); the ``GET /api/v1/graph/schema`` endpoint
now serves this registry so the client can render ports and edges generically
and the two never drift.
This registry is a *superset* of the current frontend ``buildGraph``: it also
declares real references that ``buildGraph`` does not yet draw (e.g.
``value_source.value_source_id`` chaining and ``value_source.color_strip_source_id``).
The backend is authoritative; the client is expected to converge on it.
Everything in this module is pure (operates on plain dicts), so the topology
build, dependency lookup, cycle and dangling-reference detection are all unit
testable without booting the app or any store.
Field-path grammar (the ``field`` of a :class:`ConnectionField`):
* ``"device_id"`` — a top-level string id.
* ``"brightness.source_id"`` — a nested object; ``brightness`` may be a
plain number (unbound :class:`BindableFloat`) or ``{"value", "source_id"}``.
* ``"settings.pattern_template_id"`` — arbitrarily deep object access.
* ``"layers[].source_id"`` — ``layers`` is a list; read ``source_id``
from every element.
* ``"calibration.lines[].picture_source_id"`` — object → list → field.
"""
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass, is_dataclass
from typing import Any
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ConnectionField:
"""One connectable reference: ``target_kind.field`` points at ``source_kind``."""
target_kind: str
"""Entity kind that *holds* the reference (the consumer / referrer)."""
field: str
"""Dot-path to the reference value (see module docstring grammar)."""
source_kind: str
"""Entity kind being referenced (the producer / source)."""
edge_type: str
"""Edge category, used by the client for colour and port grouping."""
bindable: bool = False
"""True when the slot is a :class:`BindableFloat`/``BindableColor`` value binding."""
nested: bool = False
"""True when the field lives inside a nested object/list (dotted path)."""
@property
def is_list(self) -> bool:
"""True when any path segment iterates a list (``foo[]``)."""
return "[]" in self.field
# ── Entity kinds & their human "type" attribute ────────────────────────────
# Mirrors the frontend buildGraph(): kind → the serialized field that carries
# the entity's subtype (used only for the node label / icon).
NODE_TYPE_FIELD: dict[str, str] = {
"device": "device_type",
"capture_template": "engine_type",
"pp_template": "",
"audio_template": "engine_type",
"pattern_template": "",
"picture_source": "stream_type",
"audio_source": "source_type",
"value_source": "source_type",
"color_strip_source": "source_type",
"sync_clock": "",
"output_target": "target_type",
"scene_preset": "",
"automation": "",
"cspt": "",
}
ENTITY_KINDS: tuple[str, ...] = tuple(NODE_TYPE_FIELD.keys())
# ── The registry ───────────────────────────────────────────────────────────
# NOTE: ``gradient`` and ``ha_source`` reference fields are intentionally
# omitted — they are not first-class graph node kinds, so wiring them would
# only ever produce dangling-reference noise.
CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
# ── Picture sources ──
ConnectionField("picture_source", "capture_template_id", "capture_template", "template"),
ConnectionField("picture_source", "source_stream_id", "picture_source", "picture"),
ConnectionField("picture_source", "postprocessing_template_id", "pp_template", "template"),
# ── Audio sources ──
ConnectionField("audio_source", "audio_template_id", "audio_template", "audio"),
ConnectionField("audio_source", "audio_source_id", "audio_source", "audio"),
# ── Value sources ──
ConnectionField("value_source", "audio_source_id", "audio_source", "audio"),
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
ConnectionField("value_source", "value_source_id", "value_source", "value"),
ConnectionField("value_source", "color_strip_source_id", "color_strip_source", "colorstrip"),
# ── Color strip sources (top-level) ──
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
ConnectionField("color_strip_source", "clock_id", "sync_clock", "clock"),
ConnectionField("color_strip_source", "input_source_id", "color_strip_source", "colorstrip"),
ConnectionField("color_strip_source", "processing_template_id", "cspt", "template"),
# ── Color strip sources (BindableFloat value bindings) ──
*(
ConnectionField(
"color_strip_source",
f"{prop}.source_id",
"value_source",
"value",
bindable=True,
nested=True,
)
for prop in (
"smoothing",
"sensitivity",
"intensity",
"scale",
"speed",
"wind_strength",
"temperature_influence",
"sound_volume",
"timeout",
"brightness",
)
),
# ── Color strip sources (BindableColor value bindings) ──
*(
ConnectionField(
"color_strip_source",
f"{prop}.source_id",
"value_source",
"value",
bindable=True,
nested=True,
)
for prop in ("color", "color_peak", "fallback_color", "default_color")
),
# ── Color strip sources (composite layers / mapped zones / calibration) ──
ConnectionField(
"color_strip_source", "layers[].source_id", "color_strip_source", "colorstrip", nested=True
),
ConnectionField(
"color_strip_source",
"layers[].brightness_source_id",
"value_source",
"value",
bindable=True,
nested=True,
),
ConnectionField(
"color_strip_source", "layers[].processing_template_id", "cspt", "template", nested=True
),
ConnectionField(
"color_strip_source", "zones[].source_id", "color_strip_source", "colorstrip", nested=True
),
ConnectionField(
"color_strip_source",
"calibration.lines[].picture_source_id",
"picture_source",
"picture",
nested=True,
),
# ── Output targets ──
ConnectionField("output_target", "device_id", "device", "device"),
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
ConnectionField("output_target", "picture_source_id", "picture_source", "picture"),
ConnectionField(
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
),
ConnectionField(
"output_target", "transition.source_id", "value_source", "value", bindable=True, nested=True
),
ConnectionField(
"output_target", "settings.pattern_template_id", "pattern_template", "template", nested=True
),
ConnectionField(
"output_target",
"settings.brightness.source_id",
"value_source",
"value",
bindable=True,
nested=True,
),
# ── Scene presets ──
ConnectionField("scene_preset", "targets[].target_id", "output_target", "scene", nested=True),
# ── Automations ──
ConnectionField("automation", "scene_preset_id", "scene_preset", "scene"),
ConnectionField("automation", "deactivation_scene_preset_id", "scene_preset", "scene"),
# ── Devices ──
ConnectionField("device", "default_css_processing_template_id", "cspt", "template"),
)
def schema_for_kind(kind: str) -> list[ConnectionField]:
"""Every connectable field whose *referrer* is ``kind``."""
return [c for c in CONNECTION_SCHEMA if c.target_kind == kind]
def schema_as_dicts() -> list[dict[str, Any]]:
"""Serialize the registry for the ``/graph/schema`` endpoint."""
return [
{
"target_kind": c.target_kind,
"field": c.field,
"source_kind": c.source_kind,
"edge_type": c.edge_type,
"bindable": c.bindable,
"nested": c.nested,
"is_list": c.is_list,
}
for c in CONNECTION_SCHEMA
]
# ── Reference extraction ────────────────────────────────────────────────────
def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]:
"""Resolve a (possibly nested/list) ``field_path`` to its referenced ids.
Returns only non-empty string ids. Tolerant of missing keys, ``None``
values and unbound bindables (a plain number where an object was expected).
"""
current: list[Any] = [entity]
for segment in field_path.split("."):
is_list = segment.endswith("[]")
key = segment[:-2] if is_list else segment
nxt: list[Any] = []
for obj in current:
if not isinstance(obj, dict):
continue
val = obj.get(key)
if is_list:
if isinstance(val, list):
nxt.extend(val)
elif val is not None:
nxt.append(val)
current = nxt
return [v for v in current if isinstance(v, str) and v]
def serialize_entity(model: Any) -> dict[str, Any]:
"""Best-effort serialize a storage model to a plain dict for graph use.
Prefers ``dataclasses.asdict`` (pure structural, recurses bindables/lists,
invokes no managers), falling back to ``to_dict()`` then ``{}``.
"""
if is_dataclass(model) and not isinstance(model, type):
try:
return asdict(model)
except Exception as exc: # noqa: BLE001 — defensive: never let one model break the graph
logger.debug("graph: asdict failed for %r: %s", type(model).__name__, exc)
to_dict = getattr(model, "to_dict", None)
if callable(to_dict):
try:
result = to_dict()
if isinstance(result, dict):
return result
except Exception as exc: # noqa: BLE001
logger.debug("graph: to_dict failed for %r: %s", type(model).__name__, exc)
logger.warning(
"graph: could not serialize model %r; excluding from graph", type(model).__name__
)
return {}
# ── Topology / validation ───────────────────────────────────────────────────
def _node_from(kind: str, entity: dict[str, Any]) -> dict[str, Any] | None:
eid = entity.get("id")
if not isinstance(eid, str) or not eid:
return None
type_field = NODE_TYPE_FIELD.get(kind, "")
subtype = entity.get(type_field, "") if type_field else ""
return {
"id": eid,
"kind": kind,
"name": entity.get("name") or eid,
"type": subtype if isinstance(subtype, str) else "",
}
def build_topology(entities_by_kind: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
"""Build the full wiring graph + a validation report.
Args:
entities_by_kind: ``{kind: [serialized_entity_dict, ...]}``.
Returns a dict with ``nodes``, ``edges`` and ``issues`` (``orphans``,
``broken_refs``, ``cycles``).
"""
nodes: list[dict[str, Any]] = []
node_ids: set[str] = set()
for kind in ENTITY_KINDS:
for entity in entities_by_kind.get(kind, []):
node = _node_from(kind, entity)
if node and node["id"] not in node_ids:
node_ids.add(node["id"])
nodes.append(node)
edges: list[dict[str, Any]] = []
broken_refs: list[dict[str, str]] = []
for cf in CONNECTION_SCHEMA:
for entity in entities_by_kind.get(cf.target_kind, []):
referrer = entity.get("id")
if not isinstance(referrer, str) or not referrer:
continue
for ref in extract_refs(entity, cf.field):
if ref not in node_ids:
broken_refs.append({"ref": ref, "by": referrer, "field": cf.field})
continue
edges.append(
{
"from": ref,
"to": referrer,
"field": cf.field,
"edge_type": cf.edge_type,
"nested": cf.nested,
}
)
connected: set[str] = set()
for e in edges:
connected.add(e["from"])
connected.add(e["to"])
orphans = sorted(nid for nid in node_ids if nid not in connected)
cycles = sorted(detect_cycles(edges))
return {
"nodes": nodes,
"edges": edges,
"issues": {
"orphans": orphans,
"broken_refs": broken_refs,
"cycles": cycles,
},
}
def find_dependents(
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
) -> list[dict[str, str]]:
"""Return every entity that references ``(kind, entity_id)``.
``kind`` is the kind of the *referenced* entity; matching schema entries are
those whose ``source_kind == kind``.
"""
name_by_id: dict[str, str] = {}
for k in ENTITY_KINDS:
for entity in entities_by_kind.get(k, []):
eid = entity.get("id")
if isinstance(eid, str):
name_by_id[eid] = entity.get("name") or eid
dependents: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for cf in CONNECTION_SCHEMA:
if cf.source_kind != kind:
continue
for entity in entities_by_kind.get(cf.target_kind, []):
referrer = entity.get("id")
if not isinstance(referrer, str):
continue
if entity_id in extract_refs(entity, cf.field):
key = (referrer, cf.field)
if key in seen:
continue
seen.add(key)
dependents.append(
{
"id": referrer,
"kind": cf.target_kind,
"name": name_by_id.get(referrer, referrer),
"field": cf.field,
}
)
return dependents
def detect_cycles(edges: list[dict[str, Any]]) -> set[str]:
"""Return every node id that participates in a directed cycle (from→to)."""
adj: dict[str, list[str]] = {}
for e in edges:
adj.setdefault(e["from"], []).append(e["to"])
WHITE, GRAY, BLACK = 0, 1, 2
color: dict[str, int] = {}
in_cycle: set[str] = set()
for start in list(adj.keys()):
if color.get(start, WHITE) != WHITE:
continue
stack: list[tuple[str, int]] = [(start, 0)]
path: list[str] = [start]
color[start] = GRAY
while stack:
node, idx = stack[-1]
neighbors = adj.get(node, [])
if idx < len(neighbors):
stack[-1] = (node, idx + 1)
nxt = neighbors[idx]
c = color.get(nxt, WHITE)
if c == GRAY:
if nxt in path:
i = path.index(nxt)
in_cycle.update(path[i:])
elif c == WHITE:
color[nxt] = GRAY
path.append(nxt)
stack.append((nxt, 0))
else:
color[node] = BLACK
if path and path[-1] == node:
path.pop()
stack.pop()
return in_cycle
def _reachable(edges: list[dict[str, Any]], start: str, goal: str) -> bool:
"""True if ``goal`` is reachable from ``start`` following from→to edges."""
if start == goal:
return True
adj: dict[str, list[str]] = {}
for e in edges:
adj.setdefault(e["from"], []).append(e["to"])
seen = {start}
queue = [start]
while queue:
cur = queue.pop()
for nxt in adj.get(cur, []):
if nxt == goal:
return True
if nxt not in seen:
seen.add(nxt)
queue.append(nxt)
return False
def would_create_cycle(edges: list[dict[str, Any]], source_id: str, target_id: str) -> bool:
"""Would wiring ``source_id`` into ``target_id`` (edge source→target) loop?
A cycle forms if ``source_id`` is already reachable from ``target_id`` via
the existing data-flow edges (so the new edge would close the loop), or the
two are the same node.
"""
if source_id == target_id:
return True
return _reachable(edges, target_id, source_id)
def _entity_exists(
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
) -> bool:
return any(e.get("id") == entity_id for e in entities_by_kind.get(kind, []))
def validate_connection(
entities_by_kind: dict[str, list[dict[str, Any]]],
target_kind: str,
target_id: str,
field: str,
source_id: str,
) -> tuple[bool, str | None]:
"""Validate a proposed wiring edit before it is persisted.
Checks, in order: the field is a known connectable reference; the target
exists; (when not detaching) the source exists and is of the registry's
expected kind; and the edit would not create a dependency cycle. Returns
``(ok, error_message)``. Detaching (empty ``source_id``) is always allowed.
"""
cf = next(
(c for c in CONNECTION_SCHEMA if c.target_kind == target_kind and c.field == field),
None,
)
if cf is None:
return False, f"Unknown connection field: {target_kind}.{field}"
if cf.is_list:
# List slots (layers/zones/scene targets) hold many edges sharing the
# same (to, field); without an element index this endpoint can't model
# which one is being replaced for the cycle check. Edit those via the
# entity editor.
return False, f"List connection '{field}' must be edited via the entity editor"
if not _entity_exists(entities_by_kind, target_kind, target_id):
return False, f"Target entity not found: {target_id}"
if not source_id:
return True, None # detaching a slot is always valid
if not _entity_exists(entities_by_kind, cf.source_kind, source_id):
return False, f"Source {cf.source_kind} not found: {source_id}"
# Cycle check: ignore the edge currently occupying this slot, since the
# write replaces it.
topo = build_topology(entities_by_kind)
edges = [e for e in topo["edges"] if not (e["to"] == target_id and e["field"] == field)]
if would_create_cycle(edges, source_id, target_id):
return False, "Connection would create a dependency cycle"
return True, None
@@ -0,0 +1,25 @@
"""Shared MQTT-source validation for route handlers.
Both the device routes and the output-target routes accept an
``mqtt_source_id`` that must reference an existing ``MQTTSource``. This module
is the single source of truth for that check so the two callers cannot drift.
"""
from fastapi import HTTPException
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
def validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str | None) -> None:
"""Ensure a referenced MQTT source exists.
Empty / ``None`` is allowed (unconfigured = "first available broker").
Raises ``HTTPException(422)`` if a non-empty id does not resolve.
"""
if not mqtt_source_id:
return
try:
mqtt_store.get(mqtt_source_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
+47 -15
View File
@@ -11,6 +11,7 @@ import sys
import threading import threading
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -38,28 +39,59 @@ _SERVER_DIR = Path(__file__).resolve().parents[4]
def _schedule_restart() -> None: def _schedule_restart() -> None:
"""Spawn a restart script after a short delay so the HTTP response completes.""" """Spawn a restart script after a short delay so the HTTP response completes.
def _restart(): stdout/stderr of the spawned script are redirected to ``<server>/restart.log``
so a silent failure (PowerShell not on PATH, restart.ps1 erroring, etc.)
leaves evidence on disk instead of vanishing into a detached child.
"""
def _restart() -> None:
import time import time
time.sleep(1) time.sleep(1)
# Annotated as ``dict[str, Any]`` because the value union spans
# int flags (Windows ``creationflags``) and bool (POSIX
# ``start_new_session``); a narrower union confuses ``**`` unpacking.
popen_kwargs: dict[str, Any]
if sys.platform == "win32": if sys.platform == "win32":
subprocess.Popen( script = _SERVER_DIR / "restart.ps1"
[ cmd = ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(script)]
"powershell", popen_kwargs = {
"-ExecutionPolicy", "creationflags": (
"Bypass", subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
"-File", ),
str(_SERVER_DIR / "restart.ps1"), }
],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else: else:
subprocess.Popen( script = _SERVER_DIR / "restart.sh"
["bash", str(_SERVER_DIR / "restart.sh")], cmd = ["bash", str(script)]
start_new_session=True, popen_kwargs = {"start_new_session": True}
if not script.is_file():
logger.error("Restart script missing: %s", script)
return
log_path = _SERVER_DIR / "restart.log"
try:
# Open in append mode so multiple restarts accumulate; the child
# owns its own duped handle, so closing here in the parent is safe.
with open(log_path, "ab") as log_file:
log_file.write(
f"\n--- restart spawned at {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode()
) )
log_file.flush()
proc = subprocess.Popen(
cmd,
stdout=log_file,
stderr=subprocess.STDOUT,
**popen_kwargs,
)
logger.info("Restart script launched: %s (PID %s, log %s)", cmd[0], proc.pid, log_path)
except OSError as e:
logger.error("Failed to launch restart script %s: %s", script, e, exc_info=True)
except Exception as e:
logger.error("Unexpected error launching restart script: %s", e, exc_info=True)
threading.Thread(target=_restart, daemon=True).start() threading.Thread(target=_restart, daemon=True).start()
+41
View File
@@ -13,6 +13,7 @@ from ledgrab.core.devices.led_client import (
from ledgrab.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_device_store, get_device_store,
get_mqtt_store,
get_output_target_store, get_output_target_store,
get_processor_manager, get_processor_manager,
) )
@@ -33,10 +34,13 @@ from ledgrab.api.schemas.devices import (
) )
from ledgrab.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore from ledgrab.storage import DeviceStore
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.url_scheme import infer_http_scheme from ledgrab.utils.url_scheme import infer_http_scheme
from ._mqtt_validation import validate_mqtt_source_exists
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -105,6 +109,7 @@ def _device_to_response(device) -> DeviceResponse:
gamesense_device_type=device.gamesense_device_type, gamesense_device_type=device.gamesense_device_type,
ble_family=device.ble_family, ble_family=device.ble_family,
ble_govee_key=device.ble_govee_key, ble_govee_key=device.ble_govee_key,
mqtt_source_id=getattr(device, "mqtt_source_id", "") or "",
default_css_processing_template_id=device.default_css_processing_template_id, default_css_processing_template_id=device.default_css_processing_template_id,
group_device_ids=device.group_device_ids, group_device_ids=device.group_device_ids,
group_mode=device.group_mode, group_mode=device.group_mode,
@@ -124,11 +129,13 @@ async def create_device(
_auth: AuthRequired, _auth: AuthRequired,
store: DeviceStore = Depends(get_device_store), store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
): ):
"""Create and attach a new LED device.""" """Create and attach a new LED device."""
try: try:
device_type = device_data.device_type device_type = device_data.device_type
logger.info(f"Creating {device_type} device: {device_data.name}") logger.info(f"Creating {device_type} device: {device_data.name}")
validate_mqtt_source_exists(mqtt_store, device_data.mqtt_source_id)
# ── Group device: validate children + compute LED count ── # ── Group device: validate children + compute LED count ──
if device_type == "group": if device_type == "group":
@@ -287,6 +294,7 @@ async def create_device(
gamesense_device_type=device_data.gamesense_device_type or "keyboard", gamesense_device_type=device_data.gamesense_device_type or "keyboard",
ble_family=device_data.ble_family or "", ble_family=device_data.ble_family or "",
ble_govee_key=device_data.ble_govee_key or "", ble_govee_key=device_data.ble_govee_key or "",
mqtt_source_id=device_data.mqtt_source_id or "",
group_device_ids=group_device_ids, group_device_ids=group_device_ids,
group_mode=group_mode, group_mode=group_mode,
) )
@@ -543,12 +551,14 @@ async def update_device(
_auth: AuthRequired, _auth: AuthRequired,
store: DeviceStore = Depends(get_device_store), store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
): ):
"""Update device information.""" """Update device information."""
try: try:
# Group-specific validation before applying update # Group-specific validation before applying update
existing = store.get_device(device_id) existing = store.get_device(device_id)
is_group = existing.device_type == "group" is_group = existing.device_type == "group"
validate_mqtt_source_exists(mqtt_store, update_data.mqtt_source_id)
# Normalize URL the same way we do on create: # Normalize URL the same way we do on create:
# * always rstrip trailing slashes (so PUT-with-trailing-/ matches # * always rstrip trailing slashes (so PUT-with-trailing-/ matches
@@ -634,6 +644,7 @@ async def update_device(
gamesense_device_type=update_data.gamesense_device_type, gamesense_device_type=update_data.gamesense_device_type,
ble_family=update_data.ble_family, ble_family=update_data.ble_family,
ble_govee_key=update_data.ble_govee_key, ble_govee_key=update_data.ble_govee_key,
mqtt_source_id=update_data.mqtt_source_id,
group_device_ids=update_data.group_device_ids, group_device_ids=update_data.group_device_ids,
group_mode=update_data.group_mode, group_mode=update_data.group_mode,
icon=update_data.icon, icon=update_data.icon,
@@ -669,6 +680,10 @@ async def update_device(
fire_entity_event("device", "updated", device_id) fire_entity_event("device", "updated", device_id)
return _device_to_response(device) return _device_to_response(device)
except HTTPException:
# Intentional 4xx (e.g. unknown mqtt_source_id, group validation)
# must propagate unchanged — not be masked as a 500.
raise
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
@@ -777,6 +792,32 @@ async def ping_device(
# ===== WLED BRIGHTNESS ENDPOINTS ===== # ===== WLED BRIGHTNESS ENDPOINTS =====
async def resolve_device_brightness(device, manager: ProcessorManager) -> int | None:
"""Resolve a device's current brightness for aggregate/batch reads.
Mirrors GET /brightness but degrades to ``None`` instead of raising, so one
unreachable device can't fail a whole snapshot. Reads the server-side cache
first and only touches hardware when the cache is cold, then populates it so
subsequent reads are I/O-free.
"""
if "brightness_control" not in get_device_capabilities(device.device_type):
return None
ds = manager.find_device_state(device.id)
if ds and ds.hardware_brightness is not None:
return ds.hardware_brightness
try:
provider = get_provider(device.device_type)
bri = await provider.get_brightness(device.url)
if ds:
ds.hardware_brightness = bri
return bri
except NotImplementedError:
return device.software_brightness
except Exception as e:
logger.warning("Failed to resolve brightness for device %s: %s", device.id, e)
return None
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) @router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
async def get_device_brightness( async def get_device_brightness(
device_id: str, device_id: str,
+124
View File
@@ -0,0 +1,124 @@
"""Wiring-graph endpoints: schema registry, full topology, and dependents.
These power the visual graph editor (and any other client) with a single
authoritative view of how entities are wired together:
* ``GET /api/v1/graph/schema`` — the connectable-field registry.
* ``GET /api/v1/graph`` — nodes + edges + validation.
* ``GET /api/v1/graph/dependents/{kind}/{id}`` — what references an entity.
All heavy logic lives in :mod:`ledgrab.api.graph_schema` (pure, unit-tested);
this layer only gathers serialized entities from the stores and delegates.
"""
from __future__ import annotations
import logging
from typing import Any, Callable
from fastapi import APIRouter, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field
from ledgrab.api import dependencies as deps
from ledgrab.api.auth import AuthRequired
from ledgrab.api.graph_schema import (
ENTITY_KINDS,
NODE_TYPE_FIELD,
build_topology,
find_dependents,
schema_as_dicts,
serialize_entity,
validate_connection,
)
logger = logging.getLogger(__name__)
class ConnectionValidationRequest(BaseModel):
"""A proposed wiring edit: set ``target_kind.field`` to ``source_id``."""
target_kind: str
target_id: str
field: str
source_id: str = Field(default="", description="Empty string detaches the slot.")
router = APIRouter()
# kind → dependency getter for the store that owns that entity kind.
_KIND_STORES: dict[str, Callable[[], Any]] = {
"device": deps.get_device_store,
"capture_template": deps.get_template_store,
"pp_template": deps.get_pp_template_store,
"audio_template": deps.get_audio_template_store,
"pattern_template": deps.get_pattern_template_store,
"picture_source": deps.get_picture_source_store,
"audio_source": deps.get_audio_source_store,
"value_source": deps.get_value_source_store,
"color_strip_source": deps.get_color_strip_store,
"sync_clock": deps.get_sync_clock_store,
"output_target": deps.get_output_target_store,
"scene_preset": deps.get_scene_preset_store,
"automation": deps.get_automation_store,
"cspt": deps.get_cspt_store,
}
def _gather_entities() -> dict[str, list[dict[str, Any]]]:
"""Serialize every entity, keyed by kind. Missing stores yield ``[]``."""
out: dict[str, list[dict[str, Any]]] = {}
for kind, getter in _KIND_STORES.items():
try:
store = getter()
models = store.get_all()
except (
Exception
) as exc: # noqa: BLE001 — an uninitialized/failing store must not 500 the graph
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
out[kind] = []
continue
out[kind] = [serialize_entity(m) for m in models]
return out
@router.get("/api/v1/graph/schema", tags=["Graph"])
async def get_graph_schema(_auth: AuthRequired) -> dict[str, Any]:
"""Return the authoritative registry of connectable reference fields."""
return {
"kinds": list(ENTITY_KINDS),
"node_type_field": NODE_TYPE_FIELD,
"connections": schema_as_dicts(),
}
@router.get("/api/v1/graph", tags=["Graph"])
async def get_graph(_auth: AuthRequired) -> dict[str, Any]:
"""Return the full wiring topology (nodes + edges) and a validation report."""
entities = await run_in_threadpool(_gather_entities)
return build_topology(entities)
@router.get("/api/v1/graph/dependents/{kind}/{entity_id}", tags=["Graph"])
async def get_graph_dependents(kind: str, entity_id: str, _auth: AuthRequired) -> dict[str, Any]:
"""Return every entity that references ``(kind, entity_id)``."""
if kind not in ENTITY_KINDS:
raise HTTPException(status_code=404, detail=f"Unknown entity kind: {kind}")
entities = await run_in_threadpool(_gather_entities)
return {"dependents": find_dependents(entities, kind, entity_id)}
@router.post("/api/v1/graph/validate-connection", tags=["Graph"])
async def validate_graph_connection(
body: ConnectionValidationRequest, _auth: AuthRequired
) -> dict[str, Any]:
"""Validate a proposed wiring edit (existence + source kind + no cycle).
The graph editor calls this before persisting a drag-connect so it can
refuse edits that would dangle a reference or create a dependency loop.
"""
entities = await run_in_threadpool(_gather_entities)
ok, error = validate_connection(
entities, body.target_kind, body.target_id, body.field, body.source_id
)
return {"ok": ok, "error": error}
@@ -49,6 +49,8 @@ from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
from ._mqtt_validation import validate_mqtt_source_exists
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -270,16 +272,6 @@ def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found") raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
def _validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str) -> None:
"""Ensure the referenced MQTT source exists. Empty id is allowed (unconfigured)."""
if not mqtt_source_id:
return
try:
mqtt_store.get(mqtt_source_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
@router.post( @router.post(
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201 "/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
) )
@@ -333,7 +325,7 @@ async def create_target(
case Z2MLightOutputTargetCreate(): case Z2MLightOutputTargetCreate():
if data.source_kind == "color_vs": if data.source_kind == "color_vs":
_validate_color_value_source(value_source_store, data.color_value_source_id) _validate_color_value_source(value_source_store, data.color_value_source_id)
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id) validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.create_z2m_light_target( target = target_store.create_z2m_light_target(
name=data.name, name=data.name,
description=data.description, description=data.description,
@@ -540,7 +532,7 @@ async def update_target(
) )
_validate_color_value_source(value_source_store, effective_id) _validate_color_value_source(value_source_store, effective_id)
if data.mqtt_source_id: if data.mqtt_source_id:
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id) validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.update_z2m_light_target( target = target_store.update_z2m_light_target(
target_id, target_id,
name=data.name, name=data.name,
+201
View File
@@ -0,0 +1,201 @@
"""Aggregated snapshot endpoint for low-overhead polling clients.
Returns, in a single response, everything the Home Assistant integration's
coordinator needs per poll: all output targets with processing state + metrics,
all devices with brightness, the color-strip / value-source / scene-preset /
sync-clock lists, and the system block (performance, health, update).
This collapses the integration's previous ~2N+M request fan-out (per-target
``/state`` + ``/metrics`` and per-device ``/brightness``) into one round trip.
The handler delegates to the existing list/batch route handlers so the response
sub-shapes stay byte-identical to the individual endpoints — no shaping logic is
duplicated here.
Callers that don't need the whole payload can pass ``?include=`` with a
comma-separated subset of section names (the response keys). Omitting it returns
every section. Gating is per section, so an excluded section also skips its
server-side work — dropping ``device_brightness`` avoids cold-cache hardware
probes, and dropping ``system`` skips the (blocking) NVML performance query.
"""
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
get_color_strip_store,
get_device_store,
get_output_target_store,
get_processor_manager,
get_scene_preset_store,
get_sync_clock_manager,
get_sync_clock_store,
get_update_service,
get_value_source_store,
)
from ledgrab.api.schemas.update import UpdateStatusResponse
from ledgrab.utils import get_logger
from .color_strip_sources.crud import list_color_strip_sources
from .devices import list_devices, resolve_device_brightness
from .output_targets import batch_target_metrics, batch_target_states, list_targets
from .scene_presets import list_scene_presets
from .sync_clocks import list_sync_clocks
from .system import get_system_performance, health_check
from .update import get_update_status
from .value_sources import list_value_sources
logger = get_logger(__name__)
router = APIRouter()
# Selectable snapshot sections — these are exactly the response top-level keys.
SNAPSHOT_SECTIONS = (
"targets",
"target_states",
"target_metrics",
"devices",
"device_brightness",
"css_sources",
"value_sources",
"scene_presets",
"sync_clocks",
"system",
)
_SECTION_SET = frozenset(SNAPSHOT_SECTIONS)
def _resolve_sections(include: str | None) -> frozenset[str]:
"""Validate the ``include`` query param into the set of sections to emit.
``None``/empty → every section. Unknown names are rejected with 422 so a
typo fails loudly instead of silently returning a smaller payload.
"""
if not include:
return _SECTION_SET
requested = {part.strip() for part in include.split(",") if part.strip()}
unknown = requested - _SECTION_SET
if unknown:
raise HTTPException(
status_code=422,
detail=(
f"Unknown snapshot section(s): {', '.join(sorted(unknown))}. "
f"Valid sections: {', '.join(SNAPSHOT_SECTIONS)}."
),
)
return frozenset(requested)
async def _safe_section(awaitable, label: str):
"""Await a section, degrading to ``None`` on failure instead of 500-ing.
The snapshot is a resilience-oriented poll surface: one failing section
(e.g. NVML performance probing) must not fail the whole response. This
preserves the per-section fault isolation the HA coordinator relied on
before these calls were merged into one request — the coordinator already
tolerates a ``None`` section.
"""
try:
return await awaitable
except Exception:
logger.warning("snapshot: section %r failed, returning null", label, exc_info=True)
return None
async def _update_status_model(_auth, update_service) -> UpdateStatusResponse:
"""Fetch update status and coerce it through the response model.
The standalone ``/system/update/status`` endpoint declares
``response_model=UpdateStatusResponse``; coercing here keeps the snapshot's
``system.update`` field identical to that endpoint rather than emitting the
service's raw dict unfiltered.
"""
raw = await get_update_status(_auth, update_service)
return UpdateStatusResponse.model_validate(raw)
@router.get("/api/v1/snapshot", tags=["Snapshot"])
async def get_snapshot(
request: Request,
_auth: AuthRequired,
include: str | None = Query(
None,
description=(
"Comma-separated subset of sections to include. Omit for all. "
"Valid: " + ", ".join(SNAPSHOT_SECTIONS)
),
),
manager=Depends(get_processor_manager),
target_store=Depends(get_output_target_store),
device_store=Depends(get_device_store),
css_store=Depends(get_color_strip_store),
value_store=Depends(get_value_source_store),
preset_store=Depends(get_scene_preset_store),
clock_store=Depends(get_sync_clock_store),
clock_manager=Depends(get_sync_clock_manager),
update_service=Depends(get_update_service),
) -> dict[str, Any]:
"""Return the full poll payload (or a requested subset) in one response.
Shape (a key is present only when its section is requested)::
{
"targets": [<OutputTargetResponse>, ...],
"target_states": {target_id: <state>, ...},
"target_metrics": {target_id: <metrics>, ...},
"devices": [<DeviceResponse>, ...],
"device_brightness": {device_id: int | null, ...},
"css_sources": [...],
"value_sources": [...],
"scene_presets": [...],
"sync_clocks": [...],
"system": {"performance": {...}, "health": {...}, "update": {...}}
}
"""
sections = _resolve_sections(include)
result: dict[str, Any] = {}
if "targets" in sections:
result["targets"] = (await list_targets(_auth, target_store)).targets
if "target_states" in sections:
result["target_states"] = (await batch_target_states(_auth, manager))["states"]
if "target_metrics" in sections:
result["target_metrics"] = (await batch_target_metrics(_auth, manager))["metrics"]
if "devices" in sections:
result["devices"] = (await list_devices(_auth, device_store)).devices
if "device_brightness" in sections:
device_models = device_store.get_all_devices()
brightness_values = await asyncio.gather(
*(resolve_device_brightness(d, manager) for d in device_models),
return_exceptions=True,
)
result["device_brightness"] = {
model.id: (None if isinstance(value, BaseException) else value)
for model, value in zip(device_models, brightness_values)
}
if "css_sources" in sections:
css = await list_color_strip_sources(_auth, css_store, manager)
result["css_sources"] = css.sources
if "value_sources" in sections:
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
if "scene_presets" in sections:
result["scene_presets"] = (await list_scene_presets(_auth, preset_store)).presets
if "sync_clocks" in sections:
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
result["sync_clocks"] = clocks.clocks
if "system" in sections:
result["system"] = {
"performance": await _safe_section(
run_in_threadpool(get_system_performance, _auth), "system.performance"
),
"health": await _safe_section(health_check(request), "system.health"),
"update": await _safe_section(
_update_status_model(_auth, update_service), "system.update"
),
}
return result
+11
View File
@@ -131,6 +131,11 @@ class DeviceCreate(BaseModel):
None, None,
description="Govee AES key (hex) — required for encrypted Govee firmware", description="Govee AES key (hex) — required for encrypted Govee firmware",
) )
# MQTT (multi-broker) field
mqtt_source_id: str | None = Field(
None,
description="MQTT source (broker) ID for device_type=mqtt. Empty = first available broker.",
)
default_css_processing_template_id: str | None = Field( default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID" None, description="Default color strip processing template ID"
) )
@@ -217,6 +222,9 @@ class DeviceUpdate(BaseModel):
ble_govee_key: str | None = Field( ble_govee_key: str | None = Field(
None, description="Govee AES key (hex) — required for encrypted Govee firmware" None, description="Govee AES key (hex) — required for encrypted Govee firmware"
) )
mqtt_source_id: str | None = Field(
None, description="MQTT source (broker) ID for device_type=mqtt"
)
default_css_processing_template_id: str | None = Field( default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID" None, description="Default color strip processing template ID"
) )
@@ -436,6 +444,9 @@ class DeviceResponse(BaseModel):
ble_govee_key: str = Field( ble_govee_key: str = Field(
default="", description="Govee AES key (hex) — required for encrypted Govee firmware" default="", description="Govee AES key (hex) — required for encrypted Govee firmware"
) )
mqtt_source_id: str = Field(
default="", description="MQTT source (broker) ID for device_type=mqtt"
)
default_css_processing_template_id: str = Field( default_css_processing_template_id: str = Field(
default="", description="Default color strip processing template ID" default="", description="Default color strip processing template ID"
) )
@@ -84,6 +84,21 @@ class PlatformDetector:
] ]
user32.DefWindowProcW.restype = ctypes.c_ssize_t user32.DefWindowProcW.restype = ctypes.c_ssize_t
# Pin the MSG pointer type so byref(msg) matches the prototype
# (Python 3.13 ctypes rejects mismatched POINTER(MSG) caches).
LPMSG = ctypes.POINTER(ctypes.wintypes.MSG)
user32.GetMessageW.argtypes = [
LPMSG,
ctypes.wintypes.HWND,
ctypes.c_uint,
ctypes.c_uint,
]
user32.GetMessageW.restype = ctypes.c_int
user32.TranslateMessage.argtypes = [LPMSG]
user32.TranslateMessage.restype = ctypes.wintypes.BOOL
user32.DispatchMessageW.argtypes = [LPMSG]
user32.DispatchMessageW.restype = ctypes.c_ssize_t
def wnd_proc(hwnd, msg, wparam, lparam): def wnd_proc(hwnd, msg, wparam, lparam):
if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE: if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE:
try: try:
+89 -11
View File
@@ -1,10 +1,12 @@
"""Auto-backup engine — periodic SQLite snapshot backups.""" """Auto-backup engine — periodic SQLite + assets snapshot backups."""
import asyncio import asyncio
import os import os
import tempfile
import zipfile
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import List from typing import Iterable, List
from ledgrab.storage.database import Database from ledgrab.storage.database import Database
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
@@ -20,19 +22,35 @@ DEFAULT_SETTINGS = {
# Skip the immediate-on-start backup if a recent backup exists within this window. # Skip the immediate-on-start backup if a recent backup exists within this window.
_STARTUP_BACKUP_COOLDOWN = timedelta(minutes=5) _STARTUP_BACKUP_COOLDOWN = timedelta(minutes=5)
_BACKUP_EXT = ".db" # Current write format. ``.db`` is still recognised on read so backups taken
# by older versions remain listable, restorable, and prunable.
_BACKUP_EXT = ".zip"
_RECOGNISED_EXTS: tuple[str, ...] = (".zip", ".db")
# Soft warning threshold — large backups indicate an unbounded assets dir or
# bloated DB. We don't refuse to write (user data is theirs), but log loudly
# so the operator can investigate before disk fills up over many intervals.
_BACKUP_SIZE_WARN_BYTES = 500 * 1024 * 1024 # 500 MB
class AutoBackupEngine: class AutoBackupEngine:
"""Creates periodic SQLite snapshot backups of the database.""" """Creates periodic backups of the database and asset files.
Each backup is a ZIP archive containing ``ledgrab.db`` plus every file
from ``assets_dir`` under ``assets/`` — matching the format produced by
the manual ``GET /api/v1/system/backup`` download. The restore endpoint
accepts either ``.zip`` or ``.db`` interchangeably.
"""
def __init__( def __init__(
self, self,
backup_dir: Path, backup_dir: Path,
db: Database, db: Database,
assets_dir: Path | None = None,
): ):
self._backup_dir = Path(backup_dir) self._backup_dir = Path(backup_dir)
self._db = db self._db = db
self._assets_dir = Path(assets_dir) if assets_dir else None
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._last_backup_time: datetime | None = None self._last_backup_time: datetime | None = None
@@ -82,9 +100,14 @@ class AutoBackupEngine:
self._task.cancel() self._task.cancel()
self._task = None self._task = None
def _iter_backup_files(self) -> Iterable[Path]:
"""Yield every backup file (both legacy ``.db`` and current ``.zip``)."""
for ext in _RECOGNISED_EXTS:
yield from self._backup_dir.glob(f"*{ext}")
def _most_recent_backup_age(self) -> timedelta | None: def _most_recent_backup_age(self) -> timedelta | None:
"""Return the age of the newest backup file, or None if no backups exist.""" """Return the age of the newest backup file, or None if no backups exist."""
files = list(self._backup_dir.glob(f"*{_BACKUP_EXT}")) files = list(self._iter_backup_files())
if not files: if not files:
return None return None
newest = max(files, key=lambda p: p.stat().st_mtime) newest = max(files, key=lambda p: p.stat().st_mtime)
@@ -124,15 +147,72 @@ class AutoBackupEngine:
timestamp = now.strftime("%Y-%m-%dT%H%M%S") timestamp = now.strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}{_BACKUP_EXT}" filename = f"ledgrab-backup-{timestamp}{_BACKUP_EXT}"
file_path = self._backup_dir / filename file_path = self._backup_dir / filename
# Stage the ZIP at <name>.partial then os.replace into place once it's
# fully written. A crash mid-write leaves a .partial file (cleaned up
# on the next backup) but never a half-written backup that would fool
# ``_most_recent_backup_age`` / ``_prune_old_backups`` into trusting
# corrupt data.
partial_path = file_path.with_suffix(file_path.suffix + ".partial")
self._db.backup_to(file_path) # SQLite backup API → temp .db so we get a consistent snapshot
# without holding the DB lock for the ZIP write.
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
tmp_path = Path(tmp.name)
tmp.close()
asset_count = 0
try:
self._db.backup_to(tmp_path)
with zipfile.ZipFile(partial_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(tmp_path, "ledgrab.db")
if self._assets_dir and self._assets_dir.is_dir():
for asset_file in self._assets_dir.iterdir():
# Skip symlinks: ``is_file()`` follows them and we
# don't want to silently slurp a symlink target that
# lives outside the assets dir into every backup.
if asset_file.is_symlink():
continue
if asset_file.is_file():
zf.write(asset_file, f"assets/{asset_file.name}")
asset_count += 1
os.replace(partial_path, file_path)
except Exception:
# Roll back the staged partial so it doesn't accumulate; the
# finally block still removes the SQLite temp file. Re-raise so
# the caller (``_backup_loop`` / ``trigger_backup``) sees + logs
# the failure instead of silently emitting a missing backup.
partial_path.unlink(missing_ok=True)
raise
finally:
tmp_path.unlink(missing_ok=True)
# Best-effort sweep of any older orphan .partial files left by a
# crash on a previous run.
for stale in self._backup_dir.glob("*.partial"):
try:
stale.unlink()
except OSError:
pass
size_bytes = file_path.stat().st_size
self._last_backup_time = now self._last_backup_time = now
logger.info(f"Backup created: {filename}") logger.info(
"Backup created: %s (%d asset files, %.1f MB)",
filename,
asset_count,
size_bytes / (1024 * 1024),
)
if size_bytes > _BACKUP_SIZE_WARN_BYTES:
logger.warning(
"Backup %s is %.1f MB — exceeds %d MB warning threshold; "
"consider pruning the assets directory or lowering max_backups",
filename,
size_bytes / (1024 * 1024),
_BACKUP_SIZE_WARN_BYTES // (1024 * 1024),
)
def _prune_old_backups(self) -> None: def _prune_old_backups(self) -> None:
max_backups = self._settings["max_backups"] max_backups = self._settings["max_backups"]
files = sorted(self._backup_dir.glob(f"*{_BACKUP_EXT}"), key=lambda p: p.stat().st_mtime) files = sorted(self._iter_backup_files(), key=lambda p: p.stat().st_mtime)
excess = len(files) - max_backups excess = len(files) - max_backups
if excess > 0: if excess > 0:
for f in files[:excess]: for f in files[:excess]:
@@ -179,9 +259,7 @@ class AutoBackupEngine:
def list_backups(self) -> List[dict]: def list_backups(self) -> List[dict]:
backups = [] backups = []
for f in sorted( for f in sorted(self._iter_backup_files(), key=lambda p: p.stat().st_mtime, reverse=True):
self._backup_dir.glob(f"*{_BACKUP_EXT}"), key=lambda p: p.stat().st_mtime, reverse=True
):
stat = f.stat() stat = f.stat()
backups.append( backups.append(
{ {
+8
View File
@@ -15,6 +15,7 @@ def main():
import uvicorn import uvicorn
from ledgrab.config import get_config from ledgrab.config import get_config
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
config = get_config() config = get_config()
uvicorn.run( uvicorn.run(
@@ -22,7 +23,14 @@ def main():
host=config.server.host, host=config.server.host,
port=config.server.port, port=config.server.port,
log_level=config.server.log_level.lower(), log_level=config.server.log_level.lower(),
# Access logging is handled by the _access_log middleware (with token
# attribution); disable uvicorn's to avoid duplicate lines.
access_log=False,
reload=False, reload=False,
# Bound the graceful-shutdown wait so Ctrl+C with the UI open still
# runs the lifespan shutdown instead of hanging on a lingering events
# WebSocket — see shutdown_state.
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
) )
+38 -1
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import sys import sys
import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Awaitable from typing import Awaitable
@@ -74,7 +75,7 @@ config = get_config()
# The shutdown-complete signal is owned by a leaf module so ``__main__`` # The shutdown-complete signal is owned by a leaf module so ``__main__``
# can import it without dragging in this module's heavy global state. # can import it without dragging in this module's heavy global state.
from ledgrab.shutdown_state import shutdown_complete # noqa: E402 from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT, shutdown_complete # noqa: E402
def _migrate_legacy_data_location() -> None: def _migrate_legacy_data_location() -> None:
@@ -283,6 +284,7 @@ async def lifespan(app: FastAPI):
auto_backup_engine = AutoBackupEngine( auto_backup_engine = AutoBackupEngine(
backup_dir=_data_dir / "backups", backup_dir=_data_dir / "backups",
db=db, db=db,
assets_dir=Path(config.assets.assets_dir),
) )
# Create update service (checks for new releases) # Create update service (checks for new releases)
@@ -576,6 +578,33 @@ async def _security_headers(request: Request, call_next):
return response return response
# Middleware: structured access log enriched with the authenticated token's
# friendly label (the key name from auth.api_keys), so requests can be
# attributed to a specific client (e.g. "homeassistant" vs "android"). The
# label is set onto request.state by verify_api_key; endpoints without auth
# (or failed auth) log "unauthenticated". Only the label is logged — never the
# token secret. Registered last so it runs outermost: it measures total
# handling time and always records the final status, even on error.
@app.middleware("http")
async def _access_log(request: Request, call_next):
start = time.perf_counter()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
return response
finally:
logger.info(
"http_request",
method=request.method,
path=request.url.path,
status=status_code,
token=getattr(request.state, "auth_label", None) or "unauthenticated",
client=request.client.host if request.client else None,
duration_ms=round((time.perf_counter() - start) * 1000, 1),
)
# ── Auth-gated OpenAPI surface ──────────────────────────────────────────── # ── Auth-gated OpenAPI surface ────────────────────────────────────────────
# Re-add the docs endpoints we disabled above, now protected by the same # Re-add the docs endpoints we disabled above, now protected by the same
# Bearer auth as the rest of the API. When auth is unconfigured, loopback # Bearer auth as the rest of the API. When auth is unconfigured, loopback
@@ -644,5 +673,13 @@ if __name__ == "__main__":
host=config.server.host, host=config.server.host,
port=config.server.port, port=config.server.port,
log_level=config.server.log_level.lower(), log_level=config.server.log_level.lower(),
# Our _access_log middleware emits a richer structured line (incl. the
# authenticated token label), so suppress uvicorn's default access log
# to avoid two lines per request.
access_log=False,
reload=False, # Disabled due to watchfiles infinite reload loop reload=False, # Disabled due to watchfiles infinite reload loop
# Bound the graceful-shutdown wait so Ctrl+C with the UI open still
# runs the lifespan shutdown (stop targets + DB checkpoint) instead of
# hanging on a lingering events WebSocket — see shutdown_state.
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
) )
+12
View File
@@ -16,3 +16,15 @@ they release Windows / unblock only once cleanup is genuinely done.
import threading import threading
shutdown_complete: threading.Event = threading.Event() shutdown_complete: threading.Event = threading.Event()
# Bound uvicorn's graceful-shutdown wait (``uvicorn.Config(timeout_graceful_shutdown=...)``).
# uvicorn defaults this to ``None`` — ``Server.shutdown()`` then waits *forever*
# for open connections (and their tasks) to drain before it runs the lifespan
# shutdown. The events WebSocket handler blocks on ``queue.get()`` and the
# browser auto-reconnects, so connections never drain on their own. Without a
# bound, the lifespan shutdown — which stops LED targets and checkpoints the
# DB — never runs: targets stay lit and the process can't exit (leftover
# processor threads). Shared by both the desktop (__main__) and Android
# (android_entry) launchers. Keep it small so OS-shutdown cleanup still fits
# Windows' ~20 s budget; it is spent BEFORE the lifespan's own ~16 s budget.
GRACEFUL_SHUTDOWN_TIMEOUT: int = 3
@@ -495,6 +495,18 @@ html:has(#tab-graph.active) {
opacity: 0.95; opacity: 0.95;
} }
/* Custom per-entity icon: the embedded SVG strokes with currentColor, so it is
tinted via `color` (default muted; the node's icon_color overrides inline). */
.graph-node-custom-icon {
color: var(--lux-ink-mute, var(--text-muted));
opacity: 0.9;
}
.graph-node.running .graph-node-custom-icon {
color: var(--ch-signal, var(--primary-color));
opacity: 1;
}
/* ── Running indicator (animated gradient border + signal-flow glow) ── */ /* ── Running indicator (animated gradient border + signal-flow glow) ── */
.graph-node.running .graph-node-body { .graph-node.running .graph-node-body {
@@ -588,6 +600,21 @@ html:has(#tab-graph.active) {
filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color))); filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color)));
} }
/* Whole-node drop targets: a source can be dropped on any compatible node to
wire one of its slots — including empty slots that have no input port yet. */
.graph-svg.connecting .graph-node-compatible .graph-node-body {
stroke: var(--ch-signal, var(--primary-color));
stroke-dasharray: 4 3;
stroke-width: 1.5;
}
.graph-node-drop-target .graph-node-body {
stroke: var(--ch-signal, var(--primary-color)) !important;
stroke-width: 2.5 !important;
stroke-dasharray: none !important;
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent));
}
/* ── Edges ── */ /* ── Edges ── */
.graph-edge { .graph-edge {
@@ -630,6 +657,25 @@ html:has(#tab-graph.active) {
opacity: 0.4; opacity: 0.4;
} }
/* Edge field labels — hidden until zoomed in enough to read them. */
.graph-edge-label {
font-size: 9px;
font-weight: 600;
font-family: var(--font-mono, monospace);
fill: var(--text-secondary);
paint-order: stroke;
stroke: var(--lux-bg-1, var(--card-bg));
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.graph-edges.show-labels .graph-edge-label {
opacity: 0.85;
}
/* Edge type colors */ /* Edge type colors */
.graph-edge-picture { stroke: #42A5F5; color: #42A5F5; } .graph-edge-picture { stroke: #42A5F5; color: #42A5F5; }
.graph-edge-colorstrip { stroke: #66BB6A; color: #66BB6A; } .graph-edge-colorstrip { stroke: #66BB6A; color: #66BB6A; }
@@ -788,6 +834,17 @@ html:has(#tab-graph.active) {
stroke-dasharray: 4 3; stroke-dasharray: 4 3;
} }
/* ── Health overlay: configuration issues (broken refs / cycles) ── */
.graph-node.has-issue .graph-node-body {
stroke: var(--danger-color);
stroke-width: 2;
stroke-dasharray: 5 3;
}
.graph-node-issue {
color: var(--danger-color);
}
/* ── Search highlight ── */ /* ── Search highlight ── */
.graph-node.search-match .graph-node-body { .graph-node.search-match .graph-node-body {
@@ -1001,6 +1058,33 @@ html:has(#tab-graph.active) {
display: inline-block; display: inline-block;
} }
/* Issues toolbar button + count badge */
.graph-issues-btn {
position: relative;
color: var(--danger-color);
}
.graph-issues-count {
position: absolute;
top: 0;
right: 0;
transform: translate(35%, -35%);
background: var(--danger-color);
color: #fff;
border-radius: 8px;
font-size: 0.6rem;
font-weight: 700;
min-width: 14px;
height: 14px;
line-height: 14px;
text-align: center;
padding: 0 3px;
}
.graph-issues-count:empty {
display: none;
}
.graph-filter-types-popover { .graph-filter-types-popover {
display: none; display: none;
flex-direction: column; flex-direction: column;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 35 KiB

+3 -1
View File
@@ -207,7 +207,7 @@ import {
import { import {
loadGraphEditor, loadGraphEditor,
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo, toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology,
graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow, graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow,
} from './features/graph-editor.ts'; } from './features/graph-editor.ts';
@@ -625,6 +625,8 @@ Object.assign(window, {
graphZoomIn, graphZoomIn,
graphZoomOut, graphZoomOut,
graphRelayout, graphRelayout,
graphShowIssues,
graphExportTopology,
graphToggleFullscreen, graphToggleFullscreen,
graphAddEntity, graphAddEntity,
toggleToolbarOverflow, toggleToolbarOverflow,
@@ -3,12 +3,65 @@
* Supports creating, changing, and detaching connections via the graph editor. * Supports creating, changing, and detaching connections via the graph editor.
*/ */
import { apiPut } from './api-client.ts'; import { apiPut, apiPost, apiGet } from './api-client.ts';
import { import {
streamsCache, colorStripSourcesCache, valueSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache,
audioSourcesCache, outputTargetsCache, automationsCacheObj, audioSourcesCache, outputTargetsCache, automationsCacheObj,
} from './state.ts'; } from './state.ts';
/** Result of the backend pre-write connection validator. */
export interface ConnectionValidation {
ok: boolean;
error?: string | null;
}
/**
* Ask the backend whether a proposed wiring edit is valid (target/source exist,
* source is the right kind, and it would not create a dependency cycle).
*
* Fails *open*: if the validation endpoint is unavailable we return ``ok`` so
* wiring still works against older servers the per-entity PUT remains the
* source of truth, this is just an early, friendlier guard.
*/
export async function validateConnection(
targetKind: string, targetId: string, field: string, sourceId: string,
): Promise<ConnectionValidation> {
try {
return await apiPost<ConnectionValidation>('/graph/validate-connection', {
target_kind: targetKind,
target_id: targetId,
field,
source_id: sourceId,
});
} catch {
return { ok: true };
}
}
/** An entity that references another entity (one row of the dependents query). */
export interface GraphDependent {
id: string;
kind: string;
name: string;
field: string;
}
/**
* List every entity that references ``(kind, id)``. Used to warn before a
* delete would dangle other entities' references. Fails *safe* (empty list)
* if the endpoint is unavailable.
*/
export async function getDependents(kind: string, id: string): Promise<GraphDependent[]> {
try {
const res = await apiGet<{ dependents: GraphDependent[] }>(
`/graph/dependents/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`,
);
return res.dependents || [];
} catch {
return [];
}
}
/* ── Types ────────────────────────────────────────────────────── */ /* ── Types ────────────────────────────────────────────────────── */
interface ConnectionEntry { interface ConnectionEntry {
@@ -19,6 +72,13 @@ interface ConnectionEntry {
endpoint?: string; endpoint?: string;
cache?: { invalidate(): void }; cache?: { invalidate(): void };
nested?: boolean; nested?: boolean;
/**
* A single-level value-source binding (e.g. `brightness.source_id`). These
* are structurally nested but ARE drag-editable: the write goes through the
* entity's `BindableFloat.apply_update`, which merges `{source_id}` while
* preserving the static value. (List/double-nested fields stay read-only.)
*/
bindable?: boolean;
} }
interface CompatibleInput { interface CompatibleInput {
@@ -61,26 +121,26 @@ const CONNECTION_MAP: ConnectionEntry[] = [
// Output targets // Output targets
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, { targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, { targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true }, { targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true, bindable: true },
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache }, { targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
// Automations // Automations
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj }, { targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
{ targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj }, { targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
// ── BindableFloat value source edges (CSS properties) ── // ── BindableFloat value source edges (CSS properties) — drag-editable ──
{ targetKind: 'color_strip_source', field: 'smoothing.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'smoothing.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'sensitivity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'sensitivity.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'intensity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'intensity.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'scale.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'scale.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'speed.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'speed.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'wind_strength.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'wind_strength.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'temperature_influence.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'temperature_influence.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'sound_volume.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'sound_volume.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'timeout.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'timeout.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
// HA light target transition binding // HA light target transition binding
{ targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true, bindable: true },
// ── BindableColor value source edges (CSS color properties) ── // ── BindableColor value source edges (CSS color properties) ──
{ targetKind: 'color_strip_source', field: 'color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'color_peak.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true }, { targetKind: 'color_strip_source', field: 'color_peak.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
@@ -97,12 +157,23 @@ const CONNECTION_MAP: ConnectionEntry[] = [
{ targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true }, { targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true },
]; ];
/** Editable via the graph: top-level reference fields, plus single-level
* bindable value-source slots (list/double-nested fields stay read-only). */
function _isEditable(c: ConnectionEntry): boolean {
return !c.nested || !!c.bindable;
}
/** True when a field is a bindable slot (its parent is a `Bindable*`). */
export function isBindableField(targetKind: string, field: string): boolean {
return !!CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field)?.bindable;
}
/** /**
* Check if an edge (by field name) is editable via drag-connect. * Check if an edge (by field name) is editable via drag-connect.
*/ */
export function isEditableEdge(field: string): boolean { export function isEditableEdge(field: string): boolean {
const entry = CONNECTION_MAP.find(c => c.field === field); const entry = CONNECTION_MAP.find(c => c.field === field);
return entry ? !entry.nested : false; return entry ? _isEditable(entry) : false;
} }
/** /**
@@ -111,7 +182,7 @@ export function isEditableEdge(field: string): boolean {
*/ */
export function findConnection(targetKind: string, sourceKind: string, edgeType?: string): ConnectionEntry[] { export function findConnection(targetKind: string, sourceKind: string, edgeType?: string): ConnectionEntry[] {
return CONNECTION_MAP.filter(c => return CONNECTION_MAP.filter(c =>
!c.nested && _isEditable(c) &&
c.targetKind === targetKind && c.targetKind === targetKind &&
c.sourceKind === sourceKind && c.sourceKind === sourceKind &&
(!edgeType || c.edgeType === edgeType) (!edgeType || c.edgeType === edgeType)
@@ -124,7 +195,7 @@ export function findConnection(targetKind: string, sourceKind: string, edgeType?
*/ */
export function getCompatibleInputs(sourceKind: string): CompatibleInput[] { export function getCompatibleInputs(sourceKind: string): CompatibleInput[] {
return CONNECTION_MAP return CONNECTION_MAP
.filter(c => !c.nested && c.sourceKind === sourceKind) .filter(c => _isEditable(c) && c.sourceKind === sourceKind)
.map(c => ({ targetKind: c.targetKind, field: c.field, edgeType: c.edgeType })); .map(c => ({ targetKind: c.targetKind, field: c.field, edgeType: c.edgeType }));
} }
@@ -132,7 +203,7 @@ export function getCompatibleInputs(sourceKind: string): CompatibleInput[] {
* Find the connection entry for a specific edge (by target kind and field). * Find the connection entry for a specific edge (by target kind and field).
*/ */
export function getConnectionByField(targetKind: string, field: string): ConnectionEntry | undefined { export function getConnectionByField(targetKind: string, field: string): ConnectionEntry | undefined {
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested); return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && _isEditable(c));
} }
/** /**
@@ -144,11 +215,16 @@ export function getConnectionByField(targetKind: string, field: string): Connect
* @returns {Promise<boolean>} success * @returns {Promise<boolean>} success
*/ */
export async function updateConnection(targetId: string, targetKind: string, field: string, newSourceId: string | null): Promise<boolean> { export async function updateConnection(targetId: string, targetKind: string, field: string, newSourceId: string | null): Promise<boolean> {
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested); const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && _isEditable(c));
if (!entry) return false; if (!entry || !entry.endpoint) return false;
const url = entry.endpoint!.replace('{id}', targetId); const url = entry.endpoint.replace('{id}', targetId);
const body = { [field]: newSourceId }; // For a bindable slot (`<parent>.source_id`) PUT `{ <parent>: { source_id } }`
// so the backend's `Bindable*.apply_update` merges and preserves the static
// value/colour. Top-level fields keep the flat `{ field: id }` shape.
const body: Record<string, unknown> = entry.bindable
? { [field.split('.')[0]]: { source_id: newSourceId || '' } }
: { [field]: newSourceId };
try { try {
await apiPut(url, body); await apiPut(url, body);
@@ -52,6 +52,43 @@ export function renderEdges(group: SVGGElement, edges: GraphEdge[]): void {
const path = _renderEdge(edge); const path = _renderEdge(edge);
group.appendChild(path); group.appendChild(path);
} }
// Field labels rendered last so they sit above the paths. Hidden by
// default — revealed when zoomed in (`.show-labels`) or on highlight.
for (const edge of edges) {
const label = _renderEdgeLabel(edge);
if (label) group.appendChild(label);
}
}
/** Human-readable label for a reference field, e.g. `capture_template_id` → `capture template`. */
function _edgeFieldLabel(field: string): string {
return field.replace(/_id$/, '').replace(/\./g, ' ').replace(/_/g, ' ').trim();
}
/** Midpoint of the port-aware cubic bezier (its control points are horizontal
* offsets only, so the t=0.5 point is exactly the endpoint midpoint). */
function _edgeMidpoint(fromNode: GraphNodeRect, toNode: GraphNodeRect, fromPortY?: number, toPortY?: number): { x: number; y: number } {
const x1 = fromNode.x + fromNode.width;
const y1 = fromNode.y + (fromPortY ?? fromNode.height / 2);
const x2 = toNode.x;
const y2 = toNode.y + (toPortY ?? toNode.height / 2);
return { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
}
function _renderEdgeLabel(edge: GraphEdge): SVGElement | null {
if (!edge.field || !edge.fromNode || !edge.toNode) return null;
const mid = _edgeMidpoint(edge.fromNode, edge.toNode, edge.fromPortY, edge.toPortY);
const text = svgEl('text', {
class: `graph-edge-label graph-edge-label-${edge.type}`,
x: mid.x, y: mid.y - 4,
'text-anchor': 'middle',
'data-from': edge.from,
'data-to': edge.to,
'data-field': edge.field,
});
text.textContent = _edgeFieldLabel(edge.field);
return text;
} }
function _createArrowMarker(type: string): SVGElement { function _createArrowMarker(type: string): SVGElement {
@@ -263,6 +300,14 @@ export function updateEdgesForNode(group: SVGGElement, nodeId: string, nodeMap:
pathEl.setAttribute('d', d); pathEl.setAttribute('d', d);
} }
}); });
// Keep the field label pinned to the edge midpoint while dragging.
const mid = _edgeMidpoint(fromNode, toNode, edge.fromPortY, edge.toPortY);
group.querySelectorAll(`.graph-edge-label[data-from="${edge.from}"][data-to="${edge.to}"]`).forEach(lbl => {
if ((lbl.getAttribute('data-field') || '') === (edge.field || '')) {
lbl.setAttribute('x', String(mid.x));
lbl.setAttribute('y', String(mid.y - 4));
}
});
} }
} }
@@ -13,6 +13,8 @@ interface LayoutNode {
name: string; name: string;
subtype: string; subtype: string;
tags: string[]; tags: string[];
icon?: string;
iconColor?: string;
running?: boolean; running?: boolean;
x?: number; x?: number;
y?: number; y?: number;
@@ -33,6 +35,17 @@ interface LayoutResult {
nodes: Map<string, LayoutNode>; nodes: Map<string, LayoutNode>;
edges: (LayoutEdge & { points: { x: number; y: number }[] | null; fromNode: LayoutNode; toNode: LayoutNode })[]; edges: (LayoutEdge & { points: { x: number; y: number }[] | null; fromNode: LayoutNode; toNode: LayoutNode })[];
bounds: { x: number; y: number; width: number; height: number }; bounds: { x: number; y: number; width: number; height: number };
brokenRefs: BrokenRef[];
}
/** A reference field that points at an entity which no longer exists. */
export interface BrokenRef {
/** The missing (referenced) entity id. */
ref: string;
/** The id of the entity that still holds the dangling reference. */
by: string;
/** The reference field name on the referrer. */
field: string;
} }
interface PortSet { interface PortSet {
@@ -81,7 +94,7 @@ const ELK_OPTIONS = {
*/ */
export async function computeLayout(entities: EntitiesInput): Promise<LayoutResult> { export async function computeLayout(entities: EntitiesInput): Promise<LayoutResult> {
const elk = new ELK(); const elk = new ELK();
const { nodes: nodeList, edges: edgeList } = buildGraph(entities); const { nodes: nodeList, edges: edgeList, brokenRefs } = buildGraph(entities);
const elkGraph = { const elkGraph = {
id: 'root', id: 'root',
@@ -151,7 +164,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
? { x: minX, y: minY, width: maxX - minX, height: maxY - minY } ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
: { x: 0, y: 0, width: 400, height: 300 }; : { x: 0, y: 0, width: 400, height: 300 };
return { nodes: nodeMap, edges, bounds }; return { nodes: nodeMap, edges, bounds, brokenRefs };
} }
/* ── Entity color mapping ── */ /* ── Entity color mapping ── */
@@ -207,22 +220,36 @@ function edgeType(fromKind: string, toKind: string, field: string): string {
/* ── Graph builder ── */ /* ── Graph builder ── */
function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[] } { function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[]; brokenRefs: BrokenRef[] } {
const nodes: LayoutNode[] = []; const nodes: LayoutNode[] = [];
const edges: LayoutEdge[] = []; const edges: LayoutEdge[] = [];
const brokenRefs: BrokenRef[] = [];
const nodeIds = new Set<string>(); const nodeIds = new Set<string>();
// Index nodes by id so edge-building is O(1) instead of O(N) per edge.
const nodeByIdLocal = new Map<string, LayoutNode>();
function addNode(id: string, kind: string, name: string, subtype: string, extra: Record<string, any> = {}): void { function addNode(id: string, kind: string, name: string, subtype: string, extra: Record<string, any> = {}): void {
if (!id || nodeIds.has(id)) return; if (!id || nodeIds.has(id)) return;
nodeIds.add(id); nodeIds.add(id);
nodes.push({ id, kind, name: name || id, subtype: subtype || '', tags: extra.tags || [], ...extra }); const node = { id, kind, name: name || id, subtype: subtype || '', tags: extra.tags || [], ...extra };
nodes.push(node);
nodeByIdLocal.set(id, node);
} }
function addEdge(from: string, to: string, field: string, label: string = ''): void { function addEdge(from: string, to: string, field: string, label: string = ''): void {
if (!from || !to || !nodeIds.has(from) || !nodeIds.has(to)) return; if (!from || !to) return;
// The referrer (`to`) is always a current entity in these loops; if the
// referenced entity (`from`) is missing, the reference is dangling —
// record it so the editor can surface a "broken reference" warning
// instead of silently dropping the edge (the old behaviour).
if (!nodeIds.has(from)) {
if (nodeIds.has(to)) brokenRefs.push({ ref: from, by: to, field });
return;
}
if (!nodeIds.has(to)) return;
const type = edgeType( const type = edgeType(
nodes.find(n => n.id === from)?.kind ?? '', nodeByIdLocal.get(from)?.kind ?? '',
nodes.find(n => n.id === to)?.kind ?? '', nodeByIdLocal.get(to)?.kind ?? '',
field field
); );
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable // Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
@@ -230,74 +257,76 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
edges.push({ from, to, field, label, type, editable }); edges.push({ from, to, field, label, type, editable });
} }
// Every entity may carry a custom `icon` (+ `icon_color`); pass them through
// so node rendering can honour them (parity with custom node colours).
// 1. Devices // 1. Devices
for (const d of e.devices || []) { for (const d of e.devices || []) {
addNode(d.id, 'device', d.name, d.device_type, { tags: d.tags }); addNode(d.id, 'device', d.name, d.device_type, { tags: d.tags, icon: d.icon, iconColor: d.icon_color });
} }
// 2. Capture templates // 2. Capture templates
for (const t of e.captureTemplates || []) { for (const t of e.captureTemplates || []) {
addNode(t.id, 'capture_template', t.name, t.engine_type, { tags: t.tags }); addNode(t.id, 'capture_template', t.name, t.engine_type, { tags: t.tags, icon: t.icon, iconColor: t.icon_color });
} }
// 3. PP templates // 3. PP templates
for (const t of e.ppTemplates || []) { for (const t of e.ppTemplates || []) {
addNode(t.id, 'pp_template', t.name, '', { tags: t.tags }); addNode(t.id, 'pp_template', t.name, '', { tags: t.tags, icon: t.icon, iconColor: t.icon_color });
} }
// 4. Audio templates // 4. Audio templates
for (const t of e.audioTemplates || []) { for (const t of e.audioTemplates || []) {
addNode(t.id, 'audio_template', t.name, t.engine_type, { tags: t.tags }); addNode(t.id, 'audio_template', t.name, t.engine_type, { tags: t.tags, icon: t.icon, iconColor: t.icon_color });
} }
// 5. Pattern templates // 5. Pattern templates
for (const t of e.patternTemplates || []) { for (const t of e.patternTemplates || []) {
addNode(t.id, 'pattern_template', t.name, '', { tags: t.tags }); addNode(t.id, 'pattern_template', t.name, '', { tags: t.tags, icon: t.icon, iconColor: t.icon_color });
} }
// 6. Sync clocks // 6. Sync clocks
for (const c of e.syncClocks || []) { for (const c of e.syncClocks || []) {
addNode(c.id, 'sync_clock', c.name, '', { running: c.is_running !== false, tags: c.tags }); addNode(c.id, 'sync_clock', c.name, '', { running: c.is_running !== false, tags: c.tags, icon: c.icon, iconColor: c.icon_color });
} }
// 7. Picture sources // 7. Picture sources
for (const s of e.pictureSources || []) { for (const s of e.pictureSources || []) {
addNode(s.id, 'picture_source', s.name, s.stream_type, { tags: s.tags }); addNode(s.id, 'picture_source', s.name, s.stream_type, { tags: s.tags, icon: s.icon, iconColor: s.icon_color });
} }
// 8. Audio sources // 8. Audio sources
for (const s of e.audioSources || []) { for (const s of e.audioSources || []) {
addNode(s.id, 'audio_source', s.name, s.source_type, { tags: s.tags }); addNode(s.id, 'audio_source', s.name, s.source_type, { tags: s.tags, icon: s.icon, iconColor: s.icon_color });
} }
// 9. Value sources // 9. Value sources
for (const s of e.valueSources || []) { for (const s of e.valueSources || []) {
addNode(s.id, 'value_source', s.name, s.source_type, { tags: s.tags }); addNode(s.id, 'value_source', s.name, s.source_type, { tags: s.tags, icon: s.icon, iconColor: s.icon_color });
} }
// 10. Color strip sources // 10. Color strip sources
for (const s of e.colorStripSources || []) { for (const s of e.colorStripSources || []) {
addNode(s.id, 'color_strip_source', s.name, s.source_type, { tags: s.tags }); addNode(s.id, 'color_strip_source', s.name, s.source_type, { tags: s.tags, icon: s.icon, iconColor: s.icon_color });
} }
// 11. Output targets // 11. Output targets
for (const t of e.outputTargets || []) { for (const t of e.outputTargets || []) {
addNode(t.id, 'output_target', t.name, t.target_type, { running: t.running || false, tags: t.tags }); addNode(t.id, 'output_target', t.name, t.target_type, { running: t.running || false, tags: t.tags, icon: t.icon, iconColor: t.icon_color });
} }
// 12. Scene presets // 12. Scene presets
for (const s of e.scenePresets || []) { for (const s of e.scenePresets || []) {
addNode(s.id, 'scene_preset', s.name, '', { tags: s.tags }); addNode(s.id, 'scene_preset', s.name, '', { tags: s.tags, icon: s.icon, iconColor: s.icon_color });
} }
// 13. Automations // 13. Automations
for (const a of e.automations || []) { for (const a of e.automations || []) {
addNode(a.id, 'automation', a.name, '', { running: a.enabled || false, tags: a.tags }); addNode(a.id, 'automation', a.name, '', { running: a.enabled || false, tags: a.tags, icon: a.icon, iconColor: a.icon_color });
} }
// 14. Color strip processing templates (CSPT) // 14. Color strip processing templates (CSPT)
for (const t of e.csptTemplates || []) { for (const t of e.csptTemplates || []) {
addNode(t.id, 'cspt', t.name, '', { tags: t.tags }); addNode(t.id, 'cspt', t.name, '', { tags: t.tags, icon: t.icon, iconColor: t.icon_color });
} }
// ── Edges ── // ── Edges ──
@@ -414,7 +443,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
if (d.default_css_processing_template_id) addEdge(d.default_css_processing_template_id, d.id, 'default_css_processing_template_id'); if (d.default_css_processing_template_id) addEdge(d.default_css_processing_template_id, d.id, 'default_css_processing_template_id');
} }
return { nodes, edges }; return { nodes, edges, brokenRefs };
} }
/* ── Port computation ── */ /* ── Port computation ── */
@@ -6,6 +6,7 @@ import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-la
import { EDGE_COLORS } from './graph-edges.ts'; import { EDGE_COLORS } from './graph-edges.ts';
import { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.ts'; import { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.ts';
import { getCardColor, setCardColor } from './card-colors.ts'; import { getCardColor, setCardColor } from './card-colors.ts';
import { renderDeviceIconSvg } from './device-icons.ts';
import * as P from './icon-paths.ts'; import * as P from './icon-paths.ts';
const SVG_NS = 'http://www.w3.org/2000/svg'; const SVG_NS = 'http://www.w3.org/2000/svg';
@@ -22,6 +23,8 @@ interface GraphNode {
kind: string; kind: string;
name: string; name: string;
subtype?: string; subtype?: string;
icon?: string;
iconColor?: string;
x: number; x: number;
y: number; y: number;
width: number; width: number;
@@ -360,7 +363,20 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
} }
} }
// Entity icon (right side) // Entity icon (right side). A custom per-entity icon wins over the
// kind/subtype default (parity with custom node colours); unknown icon ids
// yield '' so we fall back gracefully.
const customIconSvg = node.icon ? renderDeviceIconSvg(node.icon, { size: 16 }) : '';
if (customIconSvg) {
const iconG = svgEl('g', {
class: 'graph-node-custom-icon',
transform: `translate(${width - 28}, ${height / 2 - 8})`,
});
iconG.innerHTML = customIconSvg;
// The rendered SVG strokes with currentColor — tint via `color`.
if (node.iconColor) (iconG as unknown as SVGGElement).style.color = node.iconColor;
g.appendChild(iconG);
} else {
const iconPaths = (SUBTYPE_ICONS[kind]?.[subtype]) || KIND_ICONS[kind]; const iconPaths = (SUBTYPE_ICONS[kind]?.[subtype]) || KIND_ICONS[kind];
if (iconPaths) { if (iconPaths) {
const iconG = svgEl('g', { const iconG = svgEl('g', {
@@ -370,6 +386,7 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
iconG.innerHTML = iconPaths; iconG.innerHTML = iconPaths;
g.appendChild(iconG); g.appendChild(iconG);
} }
}
// Running dot // Running dot
if (running) { if (running) {
@@ -627,6 +644,39 @@ export function markOrphans(group: SVGGElement, nodeMap: Map<string, GraphNode>,
} }
} }
/**
* Mark nodes that have configuration issues (e.g. broken references, cycles).
* Adds a warning badge anchored to the node's top-left corner with a tooltip
* describing every problem. Call after `renderNodes`.
*/
export function markIssues(group: SVGGElement, issues: Map<string, string[]>): void {
// Clear previous markers so repeated calls don't stack badges.
group.querySelectorAll('.graph-node-issue').forEach(e => e.remove());
group.querySelectorAll('.graph-node.has-issue').forEach(n => n.classList.remove('has-issue'));
for (const [id, msgs] of issues) {
if (!msgs.length) continue;
const el = group.querySelector(`.graph-node[data-id="${id}"]`);
if (!el) continue;
el.classList.add('has-issue');
const badge = svgEl('g', { class: 'graph-node-issue' });
const icon = svgEl('g', { transform: 'translate(2, -9) scale(0.6)' });
icon.innerHTML = P.triangleAlert;
icon.setAttribute('fill', 'none');
icon.setAttribute('stroke', 'currentColor');
icon.setAttribute('stroke-width', '2.5');
icon.setAttribute('stroke-linecap', 'round');
icon.setAttribute('stroke-linejoin', 'round');
badge.appendChild(icon);
const tip = svgEl('title');
tip.textContent = msgs.join('\n');
badge.appendChild(tip);
el.appendChild(badge);
}
}
/** /**
* Update selection state on nodes. * Update selection state on nodes.
*/ */
@@ -180,6 +180,8 @@ class CSSEditorModal extends Modal {
notification_default_color: getNotificationDefaultColorSnapshot(), notification_default_color: getNotificationDefaultColorSnapshot(),
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value, notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value, notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
notification_sound: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value,
notification_volume: getNotificationVolumeSnapshot(),
notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()), notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()),
clock_id: (document.getElementById('css-editor-clock') as HTMLInputElement).value, clock_id: (document.getElementById('css-editor-clock') as HTMLInputElement).value,
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value, daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
@@ -233,15 +233,14 @@ function _overridesRenderList() {
// Wire EntitySelects for sound dropdowns // Wire EntitySelects for sound dropdowns
list.querySelectorAll<HTMLSelectElement>('.notif-override-sound').forEach(sel => { list.querySelectorAll<HTMLSelectElement>('.notif-override-sound').forEach(sel => {
const items = _getSoundAssetItems();
if (items.length > 0) {
const es = new EntitySelect({ const es = new EntitySelect({
target: sel, target: sel,
getItems: () => _getSoundAssetItems(), getItems: () => _getSoundAssetItems(),
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…', placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
allowNone: true,
noneLabel: t('color_strip.notification.sound.none'),
}); });
_overrideEntitySelects.push(es); _overrideEntitySelects.push(es);
}
}); });
} }
@@ -300,14 +299,13 @@ export function ensureNotifSoundEntitySelect() {
if (!sel) return; if (!sel) return;
_populateSoundOptions(sel); _populateSoundOptions(sel);
if (_notifSoundEntitySelect) _notifSoundEntitySelect.destroy(); if (_notifSoundEntitySelect) _notifSoundEntitySelect.destroy();
const items = _getSoundAssetItems();
if (items.length > 0) {
_notifSoundEntitySelect = new EntitySelect({ _notifSoundEntitySelect = new EntitySelect({
target: sel, target: sel,
getItems: () => _getSoundAssetItems(), getItems: () => _getSoundAssetItems(),
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…', placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
allowNone: true,
noneLabel: t('color_strip.notification.sound.none'),
}); });
}
} }
/* ── Test notification ────────────────────────────────────────── */ /* ── Test notification ────────────────────────────────────────── */
@@ -5,7 +5,7 @@
import { import {
_discoveryScanRunning, set_discoveryScanRunning, _discoveryScanRunning, set_discoveryScanRunning,
_discoveryCache, set_discoveryCache, _discoveryCache, set_discoveryCache,
csptCache, csptCache, mqttSourcesCache,
} from '../core/state.ts'; } from '../core/state.ts';
import { API_BASE, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts'; import { API_BASE, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
import { apiGet, apiPost } from '../core/api-client.ts'; import { apiGet, apiPost } from '../core/api-client.ts';
@@ -18,6 +18,7 @@ import { runPairingFlow, PairingCancelled } from './pairing-flow.ts';
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY, ICON_BLUETOOTH, ICON_LIGHTBULB, ICON_SPARKLES, ICON_PALETTE } from '../core/icons.ts'; import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY, ICON_BLUETOOTH, ICON_LIGHTBULB, ICON_SPARKLES, ICON_PALETTE } from '../core/icons.ts';
import { EntitySelect, EntityPalette } from '../core/entity-palette.ts'; import { EntitySelect, EntityPalette } from '../core/entity-palette.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts'; import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import type { MQTTSource } from '../types.ts';
class AddDeviceModal extends Modal { class AddDeviceModal extends Modal {
constructor() { super('add-device-modal'); } constructor() { super('add-device-modal'); }
@@ -44,6 +45,7 @@ class AddDeviceModal extends Modal {
opcChannel: (document.getElementById('device-opc-channel') as HTMLInputElement)?.value || '0', opcChannel: (document.getElementById('device-opc-channel') as HTMLInputElement)?.value || '0',
bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '', bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '',
bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '', bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '',
mqttSource: (document.getElementById('device-mqtt-source') as HTMLSelectElement)?.value || '',
yeelightMinInterval: (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value || '500', yeelightMinInterval: (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value || '500',
wizMinInterval: (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value || '50', wizMinInterval: (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value || '50',
lifxMinInterval: (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value || '50', lifxMinInterval: (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value || '50',
@@ -72,6 +74,7 @@ function _buildDeviceTypeItems() {
let _deviceTypeIconSelect: any = null; let _deviceTypeIconSelect: any = null;
let _csptEntitySelect: any = null; let _csptEntitySelect: any = null;
let _mqttSourceEntitySelect: any = null;
function _ensureDeviceTypeIconSelect() { function _ensureDeviceTypeIconSelect() {
const sel = document.getElementById('device-type'); const sel = document.getElementById('device-type');
@@ -104,6 +107,36 @@ function _ensureCsptEntitySelect() {
} }
} }
// MQTT broker picker for device_type=mqtt. Empty value = first available broker.
function _ensureMqttSourceSelect() {
const sel = document.getElementById('device-mqtt-source') as HTMLSelectElement | null;
if (!sel) return;
const sources: MQTTSource[] = mqttSourcesCache.data || [];
const current = sel.value;
sel.innerHTML = `<option value="">${t('device.mqtt_source.none')}</option>` +
sources.map((s: MQTTSource) => `<option value="${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`).join('');
sel.value = current;
if (_mqttSourceEntitySelect) _mqttSourceEntitySelect.destroy();
_mqttSourceEntitySelect = new EntitySelect({
target: sel,
getItems: () => (mqttSourcesCache.data || []).map((s: MQTTSource) => ({
value: s.id,
label: s.name,
icon: ICON_PALETTE,
desc: `${s.broker_host}:${s.broker_port}`,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('device.mqtt_source.none'),
} as any);
}
function _showMqttSourceField(show: boolean) {
const group = document.getElementById('device-mqtt-source-group') as HTMLElement | null;
if (group) group.style.display = show ? '' : 'none';
if (show) mqttSourcesCache.fetch().then(() => _ensureMqttSourceSelect());
}
/* ── Icon-grid DMX protocol selector ─────────────────────────── */ /* ── Icon-grid DMX protocol selector ─────────────────────────── */
function _buildDmxProtocolItems() { function _buildDmxProtocolItems() {
@@ -297,6 +330,7 @@ export function onDeviceTypeChanged() {
_showGameSenseFields(false); _showGameSenseFields(false);
_showGroupFields(false); _showGroupFields(false);
_showOpcFields(false); _showOpcFields(false);
_showMqttSourceField(false);
if (isMqttDevice(deviceType)) { if (isMqttDevice(deviceType)) {
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery // MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
@@ -314,6 +348,7 @@ export function onDeviceTypeChanged() {
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic'); if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint'); if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room'; urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
_showMqttSourceField(true);
} else if (isMockDevice(deviceType)) { } else if (isMockDevice(deviceType)) {
urlGroup.style.display = 'none'; urlGroup.style.display = 'none';
urlInput.removeAttribute('required'); urlInput.removeAttribute('required');
@@ -920,6 +955,14 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
if (goveeKeyEl && cloneData.ble_govee_key) goveeKeyEl.value = cloneData.ble_govee_key; if (goveeKeyEl && cloneData.ble_govee_key) goveeKeyEl.value = cloneData.ble_govee_key;
_updateBleGoveeKeyVisibility(); _updateBleGoveeKeyVisibility();
} }
// Prefill MQTT broker (after the source cache loads)
if (isMqttDevice(presetType) && cloneData.mqtt_source_id) {
mqttSourcesCache.fetch().then(() => {
const msEl = document.getElementById('device-mqtt-source') as HTMLSelectElement;
if (msEl) msEl.value = cloneData.mqtt_source_id;
_ensureMqttSourceSelect();
});
}
// Prefill DMX fields // Prefill DMX fields
if (isDmxDevice(presetType)) { if (isDmxDevice(presetType)) {
const dmxProto = document.getElementById('device-dmx-protocol') as HTMLSelectElement; const dmxProto = document.getElementById('device-dmx-protocol') as HTMLSelectElement;
@@ -1217,6 +1260,10 @@ export async function handleAddDevice(event: any) {
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim(); const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim();
if (goveeKey) body.ble_govee_key = goveeKey; if (goveeKey) body.ble_govee_key = goveeKey;
} }
if (isMqttDevice(deviceType)) {
const mqttSource = (document.getElementById('device-mqtt-source') as HTMLSelectElement)?.value || '';
if (mqttSource) body.mqtt_source_id = mqttSource;
}
if (isSpiDevice(deviceType)) { if (isSpiDevice(deviceType)) {
body.spi_speed_hz = parseInt((document.getElementById('device-spi-speed') as HTMLInputElement)?.value || '800000', 10); body.spi_speed_hz = parseInt((document.getElementById('device-spi-speed') as HTMLInputElement)?.value || '800000', 10);
body.spi_led_type = (document.getElementById('device-spi-led-type') as HTMLSelectElement)?.value || 'WS2812B'; body.spi_led_type = (document.getElementById('device-spi-led-type') as HTMLSelectElement)?.value || 'WS2812B';
@@ -4,7 +4,7 @@
import { import {
_deviceBrightnessCache, updateDeviceBrightness, _deviceBrightnessCache, updateDeviceBrightness,
csptCache, csptCache, mqttSourcesCache,
} from '../core/state.ts'; } from '../core/state.ts';
import { API_BASE, getHeaders, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts'; import { API_BASE, getHeaders, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
@@ -13,18 +13,19 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.ts'; import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_REFRESH, ICON_TEMPLATE, ICON_PALETTE } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts, ModMenuItemOpts } from '../core/mod-card.ts'; import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts, ModMenuItemOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.ts'; import { getBaseOrigin } from './settings.ts';
import type { Device } from '../types.ts'; import type { Device, MQTTSource } from '../types.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts'; import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { ICON_EDIT } from '../core/icons.ts'; import { ICON_EDIT } from '../core/icons.ts';
let _deviceTagsInput: any = null; let _deviceTagsInput: any = null;
let _settingsCsptEntitySelect: any = null; let _settingsCsptEntitySelect: any = null;
let _settingsMqttEntitySelect: any = null;
/* The General Settings modal groups its many conditional fields into /* The General Settings modal groups its many conditional fields into
four `.ds-section` panels (Identity / Connection / Hardware / Behavior). four `.ds-section` panels (Identity / Connection / Hardware / Behavior).
@@ -73,6 +74,29 @@ function _ensureSettingsCsptSelect() {
} }
} }
// MQTT broker picker for the settings modal (device_type=mqtt). Empty = first available.
function _ensureSettingsMqttSelect(selectedId: string = '') {
const sel = document.getElementById('settings-mqtt-source') as HTMLSelectElement | null;
if (!sel) return;
const sources: MQTTSource[] = mqttSourcesCache.data || [];
sel.innerHTML = `<option value="">${t('device.mqtt_source.none')}</option>` +
sources.map((s: MQTTSource) => `<option value="${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`).join('');
sel.value = selectedId || '';
if (_settingsMqttEntitySelect) _settingsMqttEntitySelect.destroy();
_settingsMqttEntitySelect = new EntitySelect({
target: sel,
getItems: () => (mqttSourcesCache.data || []).map((s: MQTTSource) => ({
value: s.id,
label: s.name,
icon: ICON_PALETTE,
desc: `${s.broker_host}:${s.broker_port}`,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('device.mqtt_source.none'),
} as any);
}
class DeviceSettingsModal extends Modal { class DeviceSettingsModal extends Modal {
constructor() { super('device-settings-modal'); } constructor() { super('device-settings-modal'); }
@@ -103,6 +127,7 @@ class DeviceSettingsModal extends Modal {
goveeMinInterval: (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value || '50', goveeMinInterval: (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value || '50',
nanoleafMinInterval: (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement | null)?.value || '100', nanoleafMinInterval: (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement | null)?.value || '100',
csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '', csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '',
mqttSource: (document.getElementById('settings-mqtt-source') as HTMLSelectElement | null)?.value || '',
}; };
} }
@@ -439,6 +464,8 @@ export async function showSettings(deviceId: any) {
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null; const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
const urlHint = urlGroup.querySelector('.input-hint') as HTMLElement | null; const urlHint = urlGroup.querySelector('.input-hint') as HTMLElement | null;
const urlInput = document.getElementById('settings-device-url') as HTMLInputElement; const urlInput = document.getElementById('settings-device-url') as HTMLInputElement;
const mqttSourceGroup = document.getElementById('settings-mqtt-source-group') as HTMLElement | null;
if (mqttSourceGroup) mqttSourceGroup.style.display = 'none';
if (isMock || isWs || isGroup) { if (isMock || isWs || isGroup) {
urlGroup.style.display = 'none'; urlGroup.style.display = 'none';
urlInput.removeAttribute('required'); urlInput.removeAttribute('required');
@@ -458,6 +485,7 @@ export async function showSettings(deviceId: any) {
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic'); if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint'); if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room'; urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
if (mqttSourceGroup) mqttSourceGroup.style.display = '';
} else if (isOpenrgbDevice(device.device_type)) { } else if (isOpenrgbDevice(device.device_type)) {
if (urlLabel) urlLabel.textContent = t('device.openrgb.url'); if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
if (urlHint) urlHint.textContent = t('device.openrgb.url.hint'); if (urlHint) urlHint.textContent = t('device.openrgb.url.hint');
@@ -805,6 +833,13 @@ export async function showSettings(deviceId: any) {
const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null; const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
if (csptSel) csptSel.value = device.default_css_processing_template_id || ''; if (csptSel) csptSel.value = device.default_css_processing_template_id || '';
// MQTT broker selector (mqtt devices only) — populated before snapshot
// so the dirty-check baseline matches the current broker.
if (isMqtt) {
await mqttSourcesCache.fetch();
_ensureSettingsMqttSelect(device.mqtt_source_id || '');
}
_updateSettingsSectionVisibility(); _updateSettingsSectionVisibility();
settingsModal.snapshot(); settingsModal.snapshot();
settingsModal.open(); settingsModal.open();
@@ -819,7 +854,7 @@ export async function showSettings(deviceId: any) {
} }
export function isSettingsDirty() { return settingsModal.isDirty(); } export function isSettingsDirty() { return settingsModal.isDirty(); }
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } destroyBleFamilyIconSelect('settings-ble-family'); destroyDdpColorOrderIconSelect('settings-ddp-color-order'); destroyDmxProtocolIconSelect('settings-dmx-protocol'); settingsModal.forceClose(); } export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } if (_settingsMqttEntitySelect) { _settingsMqttEntitySelect.destroy(); _settingsMqttEntitySelect = null; } destroyBleFamilyIconSelect('settings-ble-family'); destroyDdpColorOrderIconSelect('settings-ddp-color-order'); destroyDmxProtocolIconSelect('settings-dmx-protocol'); settingsModal.forceClose(); }
export function closeDeviceSettingsModal() { settingsModal.close(); } export function closeDeviceSettingsModal() { settingsModal.close(); }
export async function saveDeviceSettings() { export async function saveDeviceSettings() {
@@ -908,6 +943,9 @@ export async function saveDeviceSettings() {
const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || ''; const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || '';
body.ble_govee_key = goveeKey; body.ble_govee_key = goveeKey;
} }
if (isMqttDevice(settingsModal.deviceType)) {
body.mqtt_source_id = (document.getElementById('settings-mqtt-source') as HTMLSelectElement | null)?.value || '';
}
if (isGroup) { if (isGroup) {
const childRows = document.querySelectorAll('#settings-group-children-list .group-child-row') as NodeListOf<HTMLElement>; const childRows = document.querySelectorAll('#settings-group-children-list .group-child-row') as NodeListOf<HTMLElement>;
body.group_device_ids = Array.from(childRows).map(r => r.dataset.deviceId || '').filter(v => v !== ''); body.group_device_ids = Array.from(childRows).map(r => r.dataset.deviceId || '').filter(v => v !== '');
@@ -4,7 +4,8 @@
import { GraphCanvas } from '../core/graph-canvas.ts'; import { GraphCanvas } from '../core/graph-canvas.ts';
import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.ts'; import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.ts';
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeDisplayColor } from '../core/graph-nodes.ts'; import type { BrokenRef } from '../core/graph-layout.ts';
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, markIssues, updateSelection, getNodeDisplayColor } from '../core/graph-nodes.ts';
import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.ts'; import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.ts';
import { import {
devicesCache, captureTemplatesCache, ppTemplatesCache, devicesCache, captureTemplatesCache, ppTemplatesCache,
@@ -14,13 +15,14 @@ import {
automationsCacheObj, csptCache, automationsCacheObj, csptCache,
} from '../core/state.ts'; } from '../core/state.ts';
import { fetchWithAuth, fetchMetricsHistory } from '../core/api.ts'; import { fetchWithAuth, fetchMetricsHistory } from '../core/api.ts';
import { apiGet } from '../core/api-client.ts';
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts'; import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts'; import { createFpsSparkline } from '../core/chart-utils.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.ts'; import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge, validateConnection, getDependents } from '../core/graph-connections.ts';
import { showTypePicker } from '../core/icon-select.ts'; import { showTypePicker } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { readJson, isObject, isString, isNumber } from '../core/storage.ts'; import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.ts';
import { logError } from '../core/log.ts'; import { logError } from '../core/log.ts';
// Local type guard for AnchoredRect — `JSON.parse` returns unknown and the // Local type guard for AnchoredRect — `JSON.parse` returns unknown and the
@@ -122,8 +124,33 @@ let _dragState: DragState | null = null;
let _justDragged = false; let _justDragged = false;
let _dragListenersAdded = false; let _dragListenersAdded = false;
// Manual position overrides (persisted in memory; cleared on relayout) // Manual position overrides persisted to localStorage so a hand-arranged
let _manualPositions: Map<string, { x: number; y: number }> = new Map(); // layout survives page reloads; cleared explicitly on relayout.
const _POS_KEY = 'graph_node_positions';
function _isPositionMap(v: unknown): v is Record<string, { x: number; y: number }> {
if (!isObject(v)) return false;
return Object.values(v).every(p => isObject(p) && isNumber((p as any).x) && isNumber((p as any).y));
}
function _loadManualPositions(): Map<string, { x: number; y: number }> {
const obj = readJson(_POS_KEY, _isPositionMap);
const map = new Map<string, { x: number; y: number }>();
if (obj) for (const [id, p] of Object.entries(obj)) map.set(id, { x: p.x, y: p.y });
return map;
}
function _saveManualPositions(): void {
const obj: Record<string, { x: number; y: number }> = {};
for (const [id, p] of _manualPositions) obj[id] = p;
writeJson(_POS_KEY, obj);
}
let _manualPositions: Map<string, { x: number; y: number }> = _loadManualPositions();
// Node IDs that currently have a configuration issue (broken ref / cycle).
let _issueIds: Set<string> = new Set();
// Dangling references discovered during the last layout build.
let _brokenRefs: BrokenRef[] = [];
// Raw fetched entities by id — lets drop-resolution check which bindable slots
// a target actually has (subtype-safe), without re-fetching.
let _entitiesById: Map<string, any> = new Map();
// Rubber-band selection state // Rubber-band selection state
interface RubberBandState { startGraph: { x: number; y: number }; startClient: { x: number; y: number }; active: boolean; } interface RubberBandState { startGraph: { x: number; y: number }; startClient: { x: number; y: number }; active: boolean; }
@@ -333,7 +360,12 @@ export async function loadGraphEditor(): Promise<void> {
try { try {
const entities = await _fetchAllEntities(); const entities = await _fetchAllEntities();
const { nodes, edges, bounds } = await computeLayout(entities); // Index raw entities by id for subtype-safe bindable-slot resolution.
_entitiesById = new Map();
for (const arr of Object.values(entities)) {
if (Array.isArray(arr)) for (const ent of arr) if (ent && ent.id) _entitiesById.set(ent.id, ent);
}
const { nodes, edges, bounds, brokenRefs } = await computeLayout(entities);
// Apply manual position overrides from previous drag operations // Apply manual position overrides from previous drag operations
_applyManualPositions(nodes, edges); _applyManualPositions(nodes, edges);
@@ -341,6 +373,7 @@ export async function loadGraphEditor(): Promise<void> {
computePorts(nodes as any, edges); computePorts(nodes as any, edges);
_nodeMap = nodes as any; _nodeMap = nodes as any;
_edges = edges; _edges = edges;
_brokenRefs = brokenRefs;
_bounds = _calcBounds(nodes); _bounds = _calcBounds(nodes);
_renderGraph(container); _renderGraph(container);
} finally { } finally {
@@ -661,9 +694,147 @@ export async function graphRelayout(): Promise<void> {
if (!ok) return; if (!ok) return;
} }
_manualPositions.clear(); _manualPositions.clear();
_saveManualPositions();
await loadGraphEditor(); await loadGraphEditor();
} }
/* ── Health overlay (broken references + dependency cycles) ── */
/** Humanize a reference field name for display (e.g. `capture_template_id` → `capture template`). */
function _humanField(field: string): string {
return field.replace(/_id$/, '').replace(/\./g, ' ').replace(/_/g, ' ').trim();
}
/**
* Detect every node that participates in a directed dependency cycle.
* Iterative DFS with an explicit path stack (no deep recursion).
*/
function _detectCycles(nodeMap: Map<string, any>, edges: any[]): Set<string> {
const adj = new Map<string, string[]>();
for (const e of edges) {
if (!adj.has(e.from)) adj.set(e.from, []);
adj.get(e.from)!.push(e.to);
}
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map<string, number>();
const inCycle = new Set<string>();
for (const start of nodeMap.keys()) {
if (color.get(start)) continue; // already GRAY/BLACK
const stack: Array<{ id: string; i: number }> = [{ id: start, i: 0 }];
const path: string[] = [start];
color.set(start, GRAY);
while (stack.length) {
const frame = stack[stack.length - 1];
const neighbors = adj.get(frame.id) || [];
if (frame.i < neighbors.length) {
const v = neighbors[frame.i++];
const c = color.get(v) ?? WHITE;
if (c === GRAY) {
// Back edge → mark the whole cycle from v to the current node.
const idx = path.indexOf(v);
if (idx >= 0) for (let k = idx; k < path.length; k++) inCycle.add(path[k]);
} else if (c === WHITE) {
color.set(v, GRAY);
path.push(v);
stack.push({ id: v, i: 0 });
}
} else {
color.set(frame.id, BLACK);
path.pop();
stack.pop();
}
}
}
return inCycle;
}
/** Build the per-node issue map, render badges, and update the toolbar button. */
function _computeAndMarkIssues(nodeGroup: SVGGElement): void {
const issues = new Map<string, string[]>();
const add = (id: string, msg: string): void => {
const list = issues.get(id);
if (list) list.push(msg); else issues.set(id, [msg]);
};
// Dangling references: the referrer still exists but its target is gone.
for (const br of _brokenRefs) {
add(br.by, t('graph.issue.broken_ref', { field: _humanField(br.field) }));
}
// Dependency cycles.
if (_nodeMap && _edges) {
for (const id of _detectCycles(_nodeMap, _edges)) add(id, t('graph.issue.cycle'));
}
_issueIds = new Set(issues.keys());
markIssues(nodeGroup, issues);
_updateIssuesButton();
}
function _updateIssuesButton(): void {
const btn = document.getElementById('graph-issues-btn');
if (!btn) return;
const count = _issueIds.size;
const countEl = btn.querySelector('.graph-issues-count');
if (countEl) countEl.textContent = count > 0 ? String(count) : '';
(btn as HTMLElement).style.display = count > 0 ? '' : 'none';
}
/**
* Export the full wiring topology (nodes + edges + validation report) as a
* downloadable JSON file. This is the read-only half of "wiring blueprints":
* a shareable, inspectable snapshot. Re-importing/instantiating a blueprint
* (with id remapping) is a separate, larger feature.
*/
export async function graphExportTopology(): Promise<void> {
try {
const topo = await apiGet<unknown>('/graph');
const blob = new Blob([JSON.stringify(topo, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ledgrab-graph-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showToast(t('graph.export_done'), 'success');
} catch (e) {
showToast(e instanceof Error ? e.message : t('graph.export_failed'), 'error');
}
}
/** Frame and highlight all nodes flagged with configuration issues. */
export function graphShowIssues(): void {
if (_issueIds.size === 0 || !_nodeMap || !_canvas) {
showToast(t('graph.issues_none'), 'info');
return;
}
const ng = document.querySelector('.graph-nodes') as SVGGElement | null;
const eg = document.querySelector('.graph-edges') as SVGGElement | null;
_selectedIds = new Set(_issueIds);
if (ng) {
updateSelection(ng, _selectedIds);
ng.querySelectorAll('.graph-node').forEach((n: any) => {
n.style.opacity = _issueIds.has(n.getAttribute('data-id')) ? '1' : '0.2';
});
}
if (eg) clearEdgeHighlights(eg);
// Fit the viewport to the bounding box of the flagged nodes.
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const id of _issueIds) {
const n = _nodeMap.get(id);
if (!n) continue;
minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + n.width); maxY = Math.max(maxY, n.y + n.height);
}
if (minX !== Infinity) {
_canvas.fitAll({ x: minX, y: minY, width: maxX - minX, height: maxY - minY }, true);
}
}
// Entity kind → window function to open add/create modal + icon path // Entity kind → window function to open add/create modal + icon path
const _ico = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`; const _ico = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
const _w = window as any; const _w = window as any;
@@ -693,6 +864,25 @@ const ALL_CACHES = [
automationsCacheObj, csptCache, automationsCacheObj, csptCache,
]; ];
// entity kind → its DataCache, so a kind-scoped watcher (create-and-connect)
// only reacts to new entities of the expected kind, never an unrelated one.
const KIND_CACHE: Record<string, { data?: any[]; subscribe(fn: (d: any) => void): void; unsubscribe(fn: (d: any) => void): void }> = {
device: devicesCache,
capture_template: captureTemplatesCache,
pp_template: ppTemplatesCache,
audio_template: audioTemplatesCache,
pattern_template: patternTemplatesCache,
picture_source: streamsCache,
audio_source: audioSourcesCache,
value_source: valueSourcesCache,
color_strip_source: colorStripSourcesCache,
sync_clock: syncClocksCache,
output_target: outputTargetsCache,
scene_preset: scenePresetsCache,
automation: automationsCacheObj,
cspt: csptCache,
};
export function graphAddEntity(): void { export function graphAddEntity(): void {
const items = ADD_ENTITY_MAP.map(item => ({ const items = ADD_ENTITY_MAP.map(item => ({
value: item.kind, value: item.kind,
@@ -712,16 +902,62 @@ export function graphAddEntity(): void {
}); });
} }
/**
* Drag-from-port onto empty canvas offer to create a compatible consumer
* entity and wire it to the source in one gesture (the classic node-editor
* "drag out a new node" flow).
*/
function _promptCreateAndConnect(sourceKind: string, sourceId: string): void {
// Kinds that can consume this source (non-nested) and have an add action.
const kinds = [...new Set(getCompatibleInputs(sourceKind).map(c => c.targetKind))]
.filter(k => ADD_ENTITY_MAP.some(e => e.kind === k));
if (kinds.length === 0) {
showToast(t('graph.no_compatible_connection'), 'info');
return;
}
const items = kinds.map(k => {
const entry = ADD_ENTITY_MAP.find(e => e.kind === k)!;
return { value: k, icon: entry.icon, label: ENTITY_LABELS[k] || k.replace(/_/g, ' ') };
});
showTypePicker({
title: t('graph.create_and_connect'),
items,
onPick: (kind) => _createAndConnect(kind, sourceKind, sourceId),
});
}
/** Open the add-entity modal for `targetKind`, then wire the new entity to `sourceId`. */
function _createAndConnect(targetKind: string, sourceKind: string, sourceId: string): void {
const entry = ADD_ENTITY_MAP.find(e => e.kind === targetKind);
if (!entry) return;
_watchForNewEntity((newId) => {
const matches = findConnection(targetKind, sourceKind);
if (matches.length === 1) {
_doConnect(newId, targetKind, matches[0].field, sourceId);
} else if (matches.length > 1) {
_promptConnectionField(matches, newId, targetKind, sourceId);
} else {
// No resolvable field — just refresh so the new node appears.
loadGraphEditor();
}
}, targetKind);
entry.fn();
}
// Watch for new entity creation after add-entity menu action // Watch for new entity creation after add-entity menu action
let _entityWatchCleanup: (() => void) | null = null; let _entityWatchCleanup: (() => void) | null = null;
function _watchForNewEntity(): void { function _watchForNewEntity(onNew?: (newId: string) => void, expectKind?: string): void {
// Cleanup any previous watcher // Cleanup any previous watcher
if (_entityWatchCleanup) _entityWatchCleanup(); if (_entityWatchCleanup) _entityWatchCleanup();
// Scope to the expected kind's cache when given (create-and-connect), so the
// callback never fires for an unrelated entity that happens to appear first.
const caches = (expectKind && KIND_CACHE[expectKind]) ? [KIND_CACHE[expectKind]] : ALL_CACHES;
// Snapshot all current IDs // Snapshot all current IDs
const knownIds = new Set<string>(); const knownIds = new Set<string>();
for (const cache of ALL_CACHES) { for (const cache of caches) {
for (const item of (cache.data || [])) { for (const item of (cache.data || [])) {
if (item.id) knownIds.add(item.id); if (item.id) knownIds.add(item.id);
} }
@@ -731,9 +967,12 @@ function _watchForNewEntity(): void {
if (!Array.isArray(data)) return; if (!Array.isArray(data)) return;
for (const item of data) { for (const item of data) {
if (item.id && !knownIds.has(item.id)) { if (item.id && !knownIds.has(item.id)) {
// Found a new entity — reload graph and zoom to it // Found a new entity.
const newId = item.id; const newId = item.id;
cleanup(); cleanup();
// Custom handler (e.g. create-and-connect) takes over.
if (onNew) { onNew(newId); return; }
// Default: reload graph and zoom to the new node.
loadGraphEditor().then(() => { loadGraphEditor().then(() => {
const node = _nodeMap?.get(newId); const node = _nodeMap?.get(newId);
if (node && _canvas) { if (node && _canvas) {
@@ -751,14 +990,14 @@ function _watchForNewEntity(): void {
} }
}; };
for (const cache of ALL_CACHES) cache.subscribe(handler); for (const cache of caches) cache.subscribe(handler);
// Auto-cleanup after 2 minutes (user might cancel the modal) // Auto-cleanup after 2 minutes (user might cancel the modal)
const timeout = setTimeout(cleanup, 120_000); const timeout = setTimeout(cleanup, 120_000);
function cleanup() { function cleanup() {
clearTimeout(timeout); clearTimeout(timeout);
for (const cache of ALL_CACHES) cache.unsubscribe(handler); for (const cache of caches) cache.unsubscribe(handler);
_entityWatchCleanup = null; _entityWatchCleanup = null;
} }
@@ -829,6 +1068,9 @@ function _renderGraph(container: HTMLElement): void {
}); });
markOrphans(nodeGroup, _nodeMap!, _edges!); markOrphans(nodeGroup, _nodeMap!, _edges!);
// Health overlay: flag dangling references and dependency cycles.
_computeAndMarkIssues(nodeGroup);
// Animated flow dots for running nodes // Animated flow dots for running nodes
const runningIds = new Set<string>(); const runningIds = new Set<string>();
for (const node of _nodeMap!.values()) { for (const node of _nodeMap!.values()) {
@@ -845,6 +1087,8 @@ function _renderGraph(container: HTMLElement): void {
_canvas.onZoomChange = (z) => { _canvas.onZoomChange = (z) => {
const label = container.querySelector('.graph-zoom-label'); const label = container.querySelector('.graph-zoom-label');
if (label) label.textContent = `${Math.round(z * 100)}%`; if (label) label.textContent = `${Math.round(z * 100)}%`;
// Reveal edge field labels once zoomed in enough to read them.
edgeGroup.classList.toggle('show-labels', z >= 0.9);
}; };
_canvas.onViewChange = (vp) => { _canvas.onViewChange = (vp) => {
@@ -993,6 +1237,9 @@ function _renderGraph(container: HTMLElement): void {
container.focus(); container.focus();
// Re-focus when clicking inside the graph // Re-focus when clicking inside the graph
svgEl.addEventListener('pointerdown', () => container.focus()); svgEl.addEventListener('pointerdown', () => container.focus());
// The toolbar markup hardcodes `disabled` on undo/redo; re-sync with the
// live stacks since this render may follow an undoable action.
_updateUndoRedoButtons();
_initialized = true; _initialized = true;
} }
@@ -1048,6 +1295,10 @@ function _graphHTML(): string {
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}" data-collapse> <button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg> <svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</button> </button>
<button class="btn-icon graph-issues-btn" id="graph-issues-btn" onclick="graphShowIssues()" title="${t('graph.issues')}" style="display:none" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
<span class="graph-issues-count"></span>
</button>
<button class="btn-icon" onclick="graphToggleFullscreen()" title="${t('graph.fullscreen')} (F11)" data-collapse> <button class="btn-icon" onclick="graphToggleFullscreen()" title="${t('graph.fullscreen')} (F11)" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg> <svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
</button> </button>
@@ -1098,6 +1349,10 @@ function _graphHTML(): string {
<svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg> <svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
<span>${t('graph.fullscreen')}</span> <span>${t('graph.fullscreen')}</span>
</button> </button>
<button onclick="graphExportTopology(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
<span>${t('graph.export')}</span>
</button>
<div class="graph-overflow-sep"></div> <div class="graph-overflow-sep"></div>
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()"> <button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg> <svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
@@ -1525,7 +1780,8 @@ function _onEditNode(node: any) {
fnMap[node.kind]?.(); fnMap[node.kind]?.();
} }
function _onDeleteNode(node: any) { /** Dispatch the per-entity delete (each shows its own confirm + handles API/cache). */
function _deleteNodeRaw(node: any): void {
const fnMap: any = { const fnMap: any = {
device: () => _w.removeDevice?.(node.id), device: () => _w.removeDevice?.(node.id),
capture_template: () => _w.deleteTemplate?.(node.id), capture_template: () => _w.deleteTemplate?.(node.id),
@@ -1545,6 +1801,30 @@ function _onDeleteNode(node: any) {
fnMap[node.kind]?.(); fnMap[node.kind]?.();
} }
// Guards the await gap (dependents fetch + confirm) against a double-fire from
// rapid Delete presses or trash-button + Delete on the same node.
const _deletingIds = new Set<string>();
/**
* Single-node delete from the graph: first warn if other entities reference
* this one (their wiring would break), then hand off to the per-entity delete.
*/
async function _onDeleteNode(node: any): Promise<void> {
if (_deletingIds.has(node.id)) return;
_deletingIds.add(node.id);
try {
const deps = await getDependents(node.kind, node.id);
if (deps.length > 0) {
const names = deps.slice(0, 5).map(d => d.name).join(', ') + (deps.length > 5 ? ', …' : '');
const ok = await showConfirm(t('graph.delete_with_dependents_confirm', { count: deps.length, names }));
if (!ok) return;
}
_deleteNodeRaw(node);
} finally {
_deletingIds.delete(node.id);
}
}
async function _bulkDeleteSelected(): Promise<void> { async function _bulkDeleteSelected(): Promise<void> {
const count = _selectedIds.size; const count = _selectedIds.size;
if (count < 2) return; if (count < 2) return;
@@ -1552,9 +1832,11 @@ async function _bulkDeleteSelected(): Promise<void> {
(t('graph.bulk_delete_confirm') || 'Delete {count} selected entities?').replace('{count}', String(count)) (t('graph.bulk_delete_confirm') || 'Delete {count} selected entities?').replace('{count}', String(count))
); );
if (!ok) return; if (!ok) return;
// Bulk uses the raw delegate — the single bulk confirm covers the batch, and
// per-node dependents prompts would be a dialog storm.
for (const id of _selectedIds) { for (const id of _selectedIds) {
const node = _nodeMap?.get(id); const node = _nodeMap?.get(id);
if (node) _onDeleteNode(node); if (node) _deleteNodeRaw(node);
} }
_selectedIds.clear(); _selectedIds.clear();
} }
@@ -1991,17 +2273,37 @@ function _onDragPointerUp(): void {
_justDragged = true; _justDragged = true;
requestAnimationFrame(() => { _justDragged = false; }); requestAnimationFrame(() => { _justDragged = false; });
const moved: Array<{ id: string; oldX: number; oldY: number; newX: number; newY: number }> = [];
if (_dragState.multi) { if (_dragState.multi) {
_dragState.nodes.forEach(n => { _dragState.nodes.forEach(n => {
if (n.el) n.el.classList.remove('dragging'); if (n.el) n.el.classList.remove('dragging');
const node = _nodeMap!.get(n.id); const node = _nodeMap!.get(n.id);
if (node) _manualPositions.set(n.id, { x: node.x, y: node.y }); if (node) {
_manualPositions.set(n.id, { x: node.x, y: node.y });
moved.push({ id: n.id, oldX: n.startX, oldY: n.startY, newX: node.x, newY: node.y });
}
}); });
} else { } else {
const ds = _dragState as DragStateSingle; const ds = _dragState as DragStateSingle;
ds.el.classList.remove('dragging'); ds.el.classList.remove('dragging');
const node = _nodeMap!.get(ds.nodeId); const node = _nodeMap!.get(ds.nodeId);
if (node) _manualPositions.set(ds.nodeId, { x: node.x, y: node.y }); if (node) {
_manualPositions.set(ds.nodeId, { x: node.x, y: node.y });
moved.push({ id: ds.nodeId, oldX: ds.startNode.x, oldY: ds.startNode.y, newX: node.x, newY: node.y });
}
}
// Persist the hand-arranged layout so it survives page reloads.
_saveManualPositions();
// Record an undoable move (skip no-op drags below the dead zone).
if (moved.some(m => m.oldX !== m.newX || m.oldY !== m.newY)) {
pushUndoAction({
label: t('graph.action.move'),
undo: async () => { for (const m of moved) _manualPositions.set(m.id, { x: m.oldX, y: m.oldY }); _saveManualPositions(); },
redo: async () => { for (const m of moved) _manualPositions.set(m.id, { x: m.newX, y: m.newY }); _saveManualPositions(); },
});
} }
_bounds = _calcBounds(_nodeMap); _bounds = _calcBounds(_nodeMap);
@@ -2201,11 +2503,26 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
p.classList.add('graph-port-incompatible'); p.classList.add('graph-port-incompatible');
} }
}); });
// Also highlight whole compatible target NODES, so a slot with no
// existing edge (and therefore no input port yet) is still droppable —
// the user can drop anywhere on the node body to wire an empty slot.
const compatibleKinds = new Set(compatible.map(c => c.targetKind));
nodeGroup.querySelectorAll('.graph-node').forEach(n => {
const nKind = n.getAttribute('data-kind');
const nId = n.getAttribute('data-id');
if (nId !== sourceNodeId && nKind && compatibleKinds.has(nKind)) {
n.classList.add('graph-node-compatible');
}
});
}, true); // capture phase to beat node drag }, true); // capture phase to beat node drag
if (!_connectListenersAdded) { if (!_connectListenersAdded) {
window.addEventListener('pointermove', _onConnectPointerMove); window.addEventListener('pointermove', _onConnectPointerMove);
window.addEventListener('pointerup', _onConnectPointerUp); window.addEventListener('pointerup', _onConnectPointerUp);
// pointercancel (touch interruption, capture loss) must also tear down
// the drag — otherwise the temp edge, node highlights and blockPan stick.
window.addEventListener('pointercancel', _onConnectPointerUp);
_connectListenersAdded = true; _connectListenersAdded = true;
} }
} }
@@ -2228,12 +2545,20 @@ function _onConnectPointerMove(e: PointerEvent): void {
svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('graph-port-drop-target')); svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('graph-port-drop-target'));
if (port) port.classList.add('graph-port-drop-target'); if (port) port.classList.add('graph-port-drop-target');
// Highlight the compatible node under the cursor for drop-on-node wiring
// (only when not already hovering a specific port).
svgEl.querySelectorAll('.graph-node-drop-target').forEach(n => n.classList.remove('graph-node-drop-target'));
if (!port) {
const nodeUnder = elem?.closest?.('.graph-node-compatible');
if (nodeUnder) nodeUnder.classList.add('graph-node-drop-target');
}
} }
function _onConnectPointerUp(e: PointerEvent): void { function _onConnectPointerUp(e: PointerEvent): void {
if (!_connectState) return; if (!_connectState) return;
const { sourceNodeId, sourceKind, portType, dragPath } = _connectState; const { sourceNodeId, sourceKind, startX, startY, dragPath } = _connectState;
// Clean up drag edge // Clean up drag edge
dragPath.remove(); dragPath.remove();
@@ -2241,48 +2566,132 @@ function _onConnectPointerUp(e: PointerEvent): void {
if (svgEl) svgEl.classList.remove('connecting'); if (svgEl) svgEl.classList.remove('connecting');
if (_canvas) _canvas.blockPan = false; if (_canvas) _canvas.blockPan = false;
// Clean up port highlights // Clean up port + node highlights
const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null; const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null;
if (nodeGroup) { if (nodeGroup) {
nodeGroup.querySelectorAll('.graph-port-compatible, .graph-port-incompatible, .graph-port-drop-target').forEach(p => { nodeGroup.querySelectorAll('.graph-port-compatible, .graph-port-incompatible, .graph-port-drop-target').forEach(p => {
p.classList.remove('graph-port-compatible', 'graph-port-incompatible', 'graph-port-drop-target'); p.classList.remove('graph-port-compatible', 'graph-port-incompatible', 'graph-port-drop-target');
}); });
nodeGroup.querySelectorAll('.graph-node-compatible, .graph-node-drop-target').forEach(n => {
n.classList.remove('graph-node-compatible', 'graph-node-drop-target');
});
} }
// Check if dropped on a compatible input port
const elem = document.elementFromPoint(e.clientX, e.clientY); const elem = document.elementFromPoint(e.clientX, e.clientY);
const targetPort = elem?.closest?.('.graph-port-in'); const targetPort = elem?.closest?.('.graph-port-in');
if (targetPort) { if (targetPort) {
// Dropped on a specific input port — resolve by that port's edge type.
const targetNodeId = targetPort.getAttribute('data-node-id') ?? ''; const targetNodeId = targetPort.getAttribute('data-node-id') ?? '';
const targetKind = targetPort.getAttribute('data-node-kind') ?? ''; const targetKind = targetPort.getAttribute('data-node-kind') ?? '';
const targetPortType = targetPort.getAttribute('data-port-type') ?? ''; const targetPortType = targetPort.getAttribute('data-port-type') ?? '';
if (targetNodeId !== sourceNodeId) { if (targetNodeId !== sourceNodeId) {
// Find the matching connection const matches = _availableMatches(findConnection(targetKind, sourceKind, targetPortType), targetNodeId);
const matches = findConnection(targetKind, sourceKind, targetPortType);
if (matches.length === 1) { if (matches.length === 1) {
_doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId); _doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId);
} else if (matches.length > 1) { } else if (matches.length > 1) {
// Multiple possible fields (e.g., template → picture_source could be capture or pp template) // Genuinely ambiguous: the same source kind feeds two distinct
// Resolve by source kind // fields (e.g. an automation's activation vs. deactivation scene).
const exact = matches.find(m => m.sourceKind === sourceKind); _promptConnectionField(matches, targetNodeId, targetKind, sourceNodeId);
if (exact) {
_doConnect(targetNodeId, targetKind, exact.field, sourceNodeId);
} }
} }
} else {
// Dropped on the node body (or an empty slot that has no port yet):
// resolve every connectable field for this source→target pair. This is
// what makes unconnected slots wireable from the graph.
const targetNode = elem?.closest?.('.graph-node');
if (targetNode) {
const targetNodeId = targetNode.getAttribute('data-id') ?? '';
const targetKind = targetNode.getAttribute('data-kind') ?? '';
if (targetNodeId && targetNodeId !== sourceNodeId) {
const matches = _availableMatches(findConnection(targetKind, sourceKind), targetNodeId);
if (matches.length === 1) {
_doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId);
} else if (matches.length > 1) {
_promptConnectionField(matches, targetNodeId, targetKind, sourceNodeId);
} else {
showToast(t('graph.no_compatible_connection'), 'info');
}
}
} else if (_canvas) {
// Dropped on empty canvas — offer to create a compatible consumer
// and wire it (skip when the gesture was effectively a click).
const CREATE_CONNECT_MIN_DRAG = 40; // graph units
const gp = _canvas.screenToGraph(e.clientX, e.clientY);
if (Math.hypot(gp.x - startX, gp.y - startY) > CREATE_CONNECT_MIN_DRAG) {
_promptCreateAndConnect(sourceKind, sourceNodeId);
}
} }
} }
_connectState = null; _connectState = null;
} }
/**
* Keep only the bindable slots the target entity actually exposes (subtype-safe)
* e.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
* `smoothing`. Non-bindable matches always pass through.
*/
function _availableMatches<T extends { field: string; bindable?: boolean }>(matches: T[], targetId: string): T[] {
const ent = _entitiesById.get(targetId);
return matches.filter(m => {
if (!m.bindable || !ent) return true;
return m.field.split('.')[0] in ent;
});
}
/** Ask the user which field to wire when a source maps to multiple target fields. */
function _promptConnectionField(
matches: Array<{ field: string }>,
targetNodeId: string,
targetKind: string,
sourceNodeId: string,
): void {
showTypePicker({
title: t('graph.choose_connection'),
items: matches.map(m => ({ value: m.field, icon: _ico(P.link), label: _humanField(m.field) })),
onPick: (field) => { _doConnect(targetNodeId, targetKind, field, sourceNodeId); },
});
}
/** The id currently wired into (targetId, field), or '' if the slot is empty. */
function _currentSourceFor(targetId: string, field: string): string {
const edge = _edges?.find(e => e.to === targetId && e.field === field);
return edge ? edge.from : '';
}
async function _doConnect(targetId: string, targetKind: string, field: string, sourceId: string): Promise<void> { async function _doConnect(targetId: string, targetKind: string, field: string, sourceId: string): Promise<void> {
const prevSourceId = _currentSourceFor(targetId, field);
if (prevSourceId === sourceId) return; // dropped onto the existing connection — no-op
// Pre-flight validation (existence + source kind + no dependency cycle).
const v = await validateConnection(targetKind, targetId, field, sourceId);
if (!v.ok) {
showToast(v.error || t('graph.connection_failed'), 'error');
return;
}
// Confirm before overwriting an already-occupied slot.
if (prevSourceId) {
const confirmed = await showConfirm(t('graph.replace_connection_confirm'));
if (!confirmed) return;
}
const ok = await updateConnection(targetId, targetKind, field, sourceId); const ok = await updateConnection(targetId, targetKind, field, sourceId);
if (ok) { if (ok) {
showToast(t('graph.connection_updated') || 'Connection updated', 'success'); // Record an undoable action that restores the previous slot occupant.
// The inverse ops throw on failure so `_undo`/`_redo` can keep the
// action on its stack instead of silently desyncing (updateConnection
// returns false rather than throwing on API error).
pushUndoAction({
label: t('graph.action.connect'),
undo: async () => { if (!(await updateConnection(targetId, targetKind, field, prevSourceId))) throw new Error(t('graph.connection_failed')); },
redo: async () => { if (!(await updateConnection(targetId, targetKind, field, sourceId))) throw new Error(t('graph.connection_failed')); },
});
showToast(t('graph.connection_updated'), 'success');
await loadGraphEditor(); await loadGraphEditor();
} else { } else {
showToast(t('graph.connection_failed') || 'Failed to update connection', 'error'); showToast(t('graph.connection_failed'), 'error');
} }
} }
@@ -2311,31 +2720,35 @@ export async function graphUndo(): Promise<void> { await _undo(); }
export async function graphRedo(): Promise<void> { await _redo(); } export async function graphRedo(): Promise<void> { await _redo(); }
async function _undo(): Promise<void> { async function _undo(): Promise<void> {
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; } if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo'), 'info'); return; }
const action = _undoStack.pop()!; const action = _undoStack.pop()!;
try { try {
await action.undo(); await action.undo();
_redoStack.push(action); _redoStack.push(action);
showToast(t('graph.undone') || `Undone: ${action.label}`, 'info'); showToast(t('graph.undone'), 'info');
_updateUndoRedoButtons(); _updateUndoRedoButtons();
await loadGraphEditor(); await loadGraphEditor();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); // The inverse op failed — keep the action on the undo stack so the
// user can retry, and surface the error instead of a false success.
_undoStack.push(action);
showToast(e instanceof Error ? e.message : String(e), 'error');
_updateUndoRedoButtons(); _updateUndoRedoButtons();
} }
} }
async function _redo(): Promise<void> { async function _redo(): Promise<void> {
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; } if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo'), 'info'); return; }
const action = _redoStack.pop()!; const action = _redoStack.pop()!;
try { try {
await action.redo(); await action.redo();
_undoStack.push(action); _undoStack.push(action);
showToast(t('graph.redone') || `Redone: ${action.label}`, 'info'); showToast(t('graph.redone'), 'info');
_updateUndoRedoButtons(); _updateUndoRedoButtons();
await loadGraphEditor(); await loadGraphEditor();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); _redoStack.push(action);
showToast(e instanceof Error ? e.message : String(e), 'error');
_updateUndoRedoButtons(); _updateUndoRedoButtons();
} }
} }
@@ -2402,6 +2815,28 @@ export function toggleGraphHelp(): void {
/* ── Edge context menu (right-click to detach) ── */ /* ── Edge context menu (right-click to detach) ── */
/**
* Detach a connection and record an undoable action that restores it.
* @param prevSourceId the id previously wired into the slot (for undo)
*/
async function _doDetach(to: string, targetKind: string, field: string, prevSourceId: string): Promise<boolean> {
const ok = await detachConnection(to, targetKind, field);
if (ok) {
if (prevSourceId) {
pushUndoAction({
label: t('graph.action.disconnect'),
undo: async () => { if (!(await updateConnection(to, targetKind, field, prevSourceId))) throw new Error(t('graph.connection_failed')); },
redo: async () => { if (!(await detachConnection(to, targetKind, field))) throw new Error(t('graph.disconnect_failed')); },
});
}
showToast(t('graph.connection_removed'), 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed'), 'error');
}
return ok;
}
function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLElement): void { function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLElement): void {
_dismissEdgeContextMenu(); _dismissEdgeContextMenu();
@@ -2409,6 +2844,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
const toId = edgePath.getAttribute('data-to') ?? ''; const toId = edgePath.getAttribute('data-to') ?? '';
const fromId = edgePath.getAttribute('data-from') ?? '';
const toNode = _nodeMap?.get(toId); const toNode = _nodeMap?.get(toId);
if (!toNode) return; if (!toNode) return;
@@ -2419,19 +2855,13 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'graph-edge-menu-item danger'; btn.className = 'graph-edge-menu-item danger';
btn.textContent = t('graph.disconnect') || 'Disconnect'; btn.textContent = t('graph.disconnect');
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
try { try {
_dismissEdgeContextMenu(); _dismissEdgeContextMenu();
const ok = await detachConnection(toId, toNode.kind, field); await _doDetach(toId, toNode.kind, field, fromId);
if (ok) {
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
}
} catch (err) { } catch (err) {
showToast(`${t('graph.disconnect_failed') || 'Failed to disconnect'}: ${err instanceof Error ? err.message : String(err)}`, 'error'); showToast(`${t('graph.disconnect_failed')}: ${err instanceof Error ? err.message : String(err)}`, 'error');
} }
}); });
menu.appendChild(btn); menu.appendChild(btn);
@@ -2449,16 +2879,9 @@ function _dismissEdgeContextMenu(): void {
async function _detachSelectedEdge(): Promise<void> { async function _detachSelectedEdge(): Promise<void> {
if (!_selectedEdge) return; if (!_selectedEdge) return;
const { to, field, targetKind } = _selectedEdge; const { from, to, field, targetKind } = _selectedEdge;
_selectedEdge = null; _selectedEdge = null;
await _doDetach(to, targetKind, field, from);
const ok = await detachConnection(to, targetKind, field);
if (ok) {
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
}
} }
/* ── Node hover FPS tooltip ── */ /* ── Node hover FPS tooltip ── */
+2
View File
@@ -377,6 +377,8 @@ startTargetOverlay: (...args: any[]) => any;
graphZoomIn: (...args: any[]) => any; graphZoomIn: (...args: any[]) => any;
graphZoomOut: (...args: any[]) => any; graphZoomOut: (...args: any[]) => any;
graphRelayout: (...args: any[]) => any; graphRelayout: (...args: any[]) => any;
graphShowIssues: (...args: any[]) => any;
graphExportTopology: (...args: any[]) => any;
graphToggleFullscreen: (...args: any[]) => any; graphToggleFullscreen: (...args: any[]) => any;
graphAddEntity: (...args: any[]) => any; graphAddEntity: (...args: any[]) => any;
+20
View File
@@ -362,6 +362,9 @@
"device.mqtt_topic": "MQTT Topic:", "device.mqtt_topic": "MQTT Topic:",
"device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)", "device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room", "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room",
"device.mqtt_source": "MQTT Broker:",
"device.mqtt_source.hint": "Which MQTT broker this device publishes to. Manage brokers under Integrations → MQTT Sources. Leave unset to use the first available broker.",
"device.mqtt_source.none": "— First available broker",
"device.ws_url": "Connection URL:", "device.ws_url": "Connection URL:",
"device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data", "device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data",
"device.openrgb.url": "OpenRGB URL:", "device.openrgb.url": "OpenRGB URL:",
@@ -2582,6 +2585,23 @@
"graph.tooltip.fps": "FPS", "graph.tooltip.fps": "FPS",
"graph.tooltip.errors": "Errors", "graph.tooltip.errors": "Errors",
"graph.tooltip.uptime": "Uptime", "graph.tooltip.uptime": "Uptime",
"graph.undone": "Undone",
"graph.redone": "Redone",
"graph.action.connect": "Connect",
"graph.action.disconnect": "Disconnect",
"graph.action.move": "Move node",
"graph.choose_connection": "Choose connection",
"graph.issues": "Issues",
"graph.issues_none": "No issues found",
"graph.issue.broken_ref": "Broken reference: {field}",
"graph.issue.cycle": "Part of a dependency cycle",
"graph.replace_connection_confirm": "Replace the existing connection?",
"graph.no_compatible_connection": "No compatible connection between these entities",
"graph.create_and_connect": "Create & connect…",
"graph.export": "Export graph (JSON)",
"graph.export_done": "Graph exported",
"graph.export_failed": "Failed to export graph",
"graph.delete_with_dependents_confirm": "This entity is used by {count} other(s): {names}. Delete it and break those connections?",
"automation.enabled": "Automation enabled", "automation.enabled": "Automation enabled",
"automation.disabled": "Automation disabled", "automation.disabled": "Automation disabled",
"scene_preset.activated": "Preset activated", "scene_preset.activated": "Preset activated",
+20
View File
@@ -417,6 +417,9 @@
"device.mqtt_topic": "MQTT Топик:", "device.mqtt_topic": "MQTT Топик:",
"device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)", "device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная", "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная",
"device.mqtt_source": "MQTT Брокер:",
"device.mqtt_source.hint": "К какому MQTT-брокеру публикует это устройство. Брокеры настраиваются в разделе Интеграции → Источники MQTT. Оставьте пустым, чтобы использовать первый доступный брокер.",
"device.mqtt_source.none": "— Первый доступный брокер",
"device.ws_url": "URL подключения:", "device.ws_url": "URL подключения:",
"device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных", "device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных",
"device.openrgb.url": "OpenRGB URL:", "device.openrgb.url": "OpenRGB URL:",
@@ -2264,6 +2267,23 @@
"graph.tooltip.fps": "FPS", "graph.tooltip.fps": "FPS",
"graph.tooltip.errors": "Ошибки", "graph.tooltip.errors": "Ошибки",
"graph.tooltip.uptime": "Время работы", "graph.tooltip.uptime": "Время работы",
"graph.undone": "Отменено",
"graph.redone": "Повторено",
"graph.action.connect": "Соединить",
"graph.action.disconnect": "Отсоединить",
"graph.action.move": "Переместить узел",
"graph.choose_connection": "Выберите соединение",
"graph.issues": "Проблемы",
"graph.issues_none": "Проблем не найдено",
"graph.issue.broken_ref": "Битая ссылка: {field}",
"graph.issue.cycle": "Входит в цикл зависимостей",
"graph.replace_connection_confirm": "Заменить существующее соединение?",
"graph.no_compatible_connection": "Нет совместимого соединения между этими объектами",
"graph.create_and_connect": "Создать и соединить…",
"graph.export": "Экспорт графа (JSON)",
"graph.export_done": "Граф экспортирован",
"graph.export_failed": "Не удалось экспортировать граф",
"graph.delete_with_dependents_confirm": "Этот объект используется {count} другими: {names}. Удалить и разорвать эти связи?",
"automation.enabled": "Автоматизация включена", "automation.enabled": "Автоматизация включена",
"automation.disabled": "Автоматизация выключена", "automation.disabled": "Автоматизация выключена",
"scene_preset.activated": "Пресет активирован", "scene_preset.activated": "Пресет активирован",
+20
View File
@@ -415,6 +415,9 @@
"device.mqtt_topic": "MQTT 主题:", "device.mqtt_topic": "MQTT 主题:",
"device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name", "device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅", "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅",
"device.mqtt_source": "MQTT 代理:",
"device.mqtt_source.hint": "此设备发布到哪个 MQTT 代理。在集成 → MQTT 源中管理代理。留空则使用第一个可用代理。",
"device.mqtt_source.none": "— 第一个可用代理",
"device.ws_url": "连接 URL", "device.ws_url": "连接 URL",
"device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL", "device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL",
"device.openrgb.url": "OpenRGB URL", "device.openrgb.url": "OpenRGB URL",
@@ -2260,6 +2263,23 @@
"graph.tooltip.fps": "帧率", "graph.tooltip.fps": "帧率",
"graph.tooltip.errors": "错误", "graph.tooltip.errors": "错误",
"graph.tooltip.uptime": "运行时间", "graph.tooltip.uptime": "运行时间",
"graph.undone": "已撤销",
"graph.redone": "已重做",
"graph.action.connect": "连接",
"graph.action.disconnect": "断开连接",
"graph.action.move": "移动节点",
"graph.choose_connection": "选择连接",
"graph.issues": "问题",
"graph.issues_none": "未发现问题",
"graph.issue.broken_ref": "无效引用:{field}",
"graph.issue.cycle": "属于依赖循环",
"graph.replace_connection_confirm": "替换现有连接?",
"graph.no_compatible_connection": "这些实体之间没有兼容的连接",
"graph.create_and_connect": "创建并连接…",
"graph.export": "导出图谱 (JSON)",
"graph.export_done": "图谱已导出",
"graph.export_failed": "导出图谱失败",
"graph.delete_with_dependents_confirm": "此实体被 {count} 个其他实体引用:{names}。删除并断开这些连接?",
"automation.enabled": "自动化已启用", "automation.enabled": "自动化已启用",
"automation.disabled": "自动化已禁用", "automation.disabled": "自动化已禁用",
"scene_preset.activated": "预设已激活", "scene_preset.activated": "预设已激活",
+223 -10
View File
@@ -4,20 +4,233 @@
* Strategy: * Strategy:
* - Static assets (/static/): stale-while-revalidate * - Static assets (/static/): stale-while-revalidate
* - API / config requests: network-only (device control must be live) * - API / config requests: network-only (device control must be live)
* - Navigation: network-first with offline fallback * - Navigation: network-first with branded offline fallback
*/ */
const CACHE_NAME = 'ledgrab-v34'; const CACHE_NAME = 'ledgrab-v35';
// Only pre-cache static assets (no auth required). // Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page. // Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
// The Orbitron brand font is precached so the offline page renders on-brand
// even on a device that hasn't warmed the font cache yet.
const PRECACHE_URLS = [ const PRECACHE_URLS = [
'/static/dist/app.bundle.css', '/static/dist/app.bundle.css',
'/static/dist/app.bundle.js', '/static/dist/app.bundle.js',
'/static/icons/icon-192.png', '/static/icons/icon-192.png',
'/static/icons/icon-512.png', '/static/icons/icon-512.png',
'/static/fonts/orbitron-700-latin.woff2',
]; ];
// Branded offline fallback shown when a navigation can't reach the server.
// Self-contained (no CDN, no app CSS) so it renders with zero live network.
// Mirrors the "Lumenworks" console aesthetic: pure-black panel, Orbitron brand
// mark, channel-coral for the offline/alarm state, signal-green for restore.
// A background probe self-heals the page — it reloads the instant the server
// answers again, so a restarting server no longer leaves a dead-end screen.
const OFFLINE_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>LED Grab Signal Lost</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#000;--line:#1c2027;
--ink:#eef2f7;--dim:#aeb7c4;--mute:#6b7480;
--coral:#ff5e5e;
--signal:#4caf50;--signal-hi:#6fd173;
--glow-coral:0 0 18px rgba(255,94,94,.55);
--glow-signal:0 0 10px rgba(76,175,80,.8);
}
@media (prefers-color-scheme: light){
:root{
--bg:#f6f8fb;--line:#dee3ea;
--ink:#0f1419;--dim:#41505f;--mute:#7b8694;
--coral:#d8392e;
--signal:#2e7d32;--signal-hi:#3d8b40;
--glow-coral:0 0 16px rgba(216,57,46,.30);
--glow-signal:0 0 10px rgba(46,125,50,.5);
}
}
@font-face{
font-family:'Orbitron';font-style:normal;font-weight:700;font-display:swap;
src:url('/static/fonts/orbitron-700-latin.woff2') format('woff2');
}
html,body{height:100%}
body{
background:var(--bg);color:var(--ink);
font-family:'Manrope','Segoe UI',system-ui,-apple-system,BlinkMacSystemFont,sans-serif;
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
display:grid;place-items:center;min-height:100dvh;position:relative;overflow:hidden;
padding:calc(28px + env(safe-area-inset-top)) 24px calc(28px + env(safe-area-inset-bottom));
}
/* atmosphere: coral alarm vignette + faint signal floor */
body::before{
content:'';position:fixed;inset:0;z-index:0;pointer-events:none;
background:
radial-gradient(125% 80% at 50% -12%, rgba(255,94,94,.11), transparent 60%),
radial-gradient(100% 55% at 50% 118%, rgba(0,216,255,.045), transparent 60%);
}
/* fine equipment-panel scanlines */
body::after{
content:'';position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.5;
mix-blend-mode:screen;
background:repeating-linear-gradient(0deg, rgba(255,255,255,.018) 0 1px, transparent 1px 3px);
}
@media (prefers-color-scheme: light){
body::after{opacity:.4;mix-blend-mode:multiply;
background:repeating-linear-gradient(0deg, rgba(0,0,0,.02) 0 1px, transparent 1px 3px)}
}
.panel{position:relative;z-index:1;width:min(460px,100%);
display:flex;flex-direction:column;align-items:center;text-align:center}
.panel>*{opacity:0;transform:translateY(10px);animation:rise .6s cubic-bezier(.16,1,.3,1) forwards}
.brand{animation-delay:.05s}.strip-wrap{animation-delay:.14s}.chip{animation-delay:.22s}
.headline{animation-delay:.30s}.copy{animation-delay:.38s}.btn{animation-delay:.46s}
.telemetry{animation-delay:.54s}.foot{animation-delay:.62s}
@keyframes rise{to{opacity:1;transform:none}}
.brand{display:flex;align-items:center;gap:11px;margin-bottom:32px}
.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--coral);
box-shadow:var(--glow-coral);animation:beat 1.6s ease-in-out infinite}
.brand-name{font-family:'Orbitron','Segoe UI',sans-serif;font-weight:700;
font-size:.95rem;letter-spacing:.34em;text-indent:.34em;color:var(--ink)}
@keyframes beat{0%,100%{opacity:1}50%{opacity:.28}}
.strip-wrap{width:100%;margin-bottom:28px}
.strip{position:relative;display:flex;gap:7px;padding:15px 12px;overflow:hidden;
border:1px solid var(--line);border-radius:13px;
background:linear-gradient(180deg, rgba(255,255,255,.025), transparent)}
.strip i{flex:1 1 0;height:11px;border-radius:3px;background:var(--coral);opacity:.16;
transition:opacity .45s ease, background .45s ease, box-shadow .45s ease}
.strip::before{content:'';position:absolute;top:0;bottom:0;width:30%;left:-40%;
mix-blend-mode:screen;filter:blur(7px);
background:linear-gradient(90deg, transparent, var(--coral), transparent);
animation:sweep 2.4s linear infinite}
@keyframes sweep{0%{left:-42%}100%{left:112%}}
.chip{display:inline-flex;align-items:center;gap:9px;margin-bottom:24px;
font-family:'JetBrains Mono',ui-monospace,'Cascadia Code',monospace;
font-size:.66rem;font-weight:600;letter-spacing:.22em;text-transform:uppercase;
color:var(--coral);padding:5px 14px;border-radius:100px;
border:1px solid color-mix(in srgb, var(--coral) 38%, transparent)}
.chip b{width:7px;height:7px;border-radius:1px;background:var(--coral);
box-shadow:var(--glow-coral);animation:beat 1.6s ease-in-out infinite}
.headline{font-family:'Orbitron','Segoe UI',sans-serif;font-weight:700;
font-size:clamp(2.5rem,11.5vw,3.6rem);line-height:.92;letter-spacing:.015em;
text-transform:uppercase;color:var(--coral);text-shadow:0 0 26px rgba(255,94,94,.34);
margin-bottom:20px;animation:rise .6s cubic-bezier(.16,1,.3,1) forwards, flicker 5.5s 1.4s ease-in-out infinite}
.headline span{display:block;color:var(--ink);text-shadow:none}
@keyframes flicker{0%,93%,100%{opacity:1}94%{opacity:.55}96%{opacity:.85}97%{opacity:.4}}
.copy{max-width:35ch;color:var(--dim);font-size:.99rem;line-height:1.62;margin-bottom:32px}
.btn{position:relative;overflow:hidden;cursor:pointer;border:none;border-radius:11px;
padding:14px 36px;color:#04140a;background:var(--signal);
font-family:'JetBrains Mono',ui-monospace,monospace;font-weight:700;
font-size:.8rem;letter-spacing:.18em;text-transform:uppercase;
box-shadow:0 0 0 1px color-mix(in srgb, var(--signal) 55%, transparent), 0 10px 30px rgba(76,175,80,.28);
transition:transform .15s ease, box-shadow .25s ease, background .25s ease}
.btn:hover{background:var(--signal-hi);box-shadow:0 0 0 1px var(--signal), 0 14px 40px rgba(76,175,80,.42)}
.btn:active{transform:translateY(1px) scale(.99)}
.btn:focus-visible{outline:none;box-shadow:0 0 0 3px var(--bg), 0 0 0 5px var(--signal)}
.btn[disabled]{cursor:default;opacity:.7}
.btn::after{content:'';position:absolute;inset:0;transform:translateX(-130%);
background:linear-gradient(90deg, transparent, rgba(255,255,255,.38), transparent)}
.btn:not([disabled]):hover::after{animation:sheen .85s ease}
@keyframes sheen{to{transform:translateX(130%)}}
.telemetry{margin-top:18px;min-height:1.1em;
font-family:'JetBrains Mono',ui-monospace,monospace;
font-size:.7rem;letter-spacing:.12em;color:var(--mute)}
.foot{margin-top:34px;font-family:'JetBrains Mono',ui-monospace,monospace;
font-size:.6rem;letter-spacing:.3em;text-transform:uppercase;color:var(--mute);opacity:.65}
/* ── link-restored state ── */
body.online .strip i{opacity:1;background:var(--signal);box-shadow:var(--glow-signal)}
body.online .strip::before{display:none}
body.online .brand-dot,body.online .chip b{background:var(--signal);box-shadow:var(--glow-signal);animation:none}
body.online .chip{color:var(--signal);border-color:color-mix(in srgb, var(--signal) 45%, transparent)}
body.online .headline{animation:none}
@media (prefers-reduced-motion: reduce){
.panel>*,.headline{animation:none;opacity:1;transform:none}
.strip::before{display:none}.strip i{opacity:.5}
.brand-dot,.chip b{animation:none}.btn:hover::after{animation:none}
}
</style>
</head>
<body>
<main class="panel" role="alert" aria-live="assertive">
<div class="brand"><span class="brand-dot" aria-hidden="true"></span><span class="brand-name">LED&nbsp;GRAB</span></div>
<div class="strip-wrap"><div class="strip" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<p class="chip"><b aria-hidden="true"></b><span id="chip-text">No Signal</span></p>
<h1 class="headline"><span>Signal</span>Lost</h1>
<p class="copy">Can&rsquo;t reach the LED&nbsp;Grab server. Make sure it&rsquo;s running and your device is on the same network.</p>
<button id="retry" class="btn" type="button"><span id="btn-label">Reconnect</span></button>
<p class="telemetry" id="telemetry" aria-live="polite">Listening for the server&hellip;</p>
<p class="foot">LED&nbsp;Grab &middot; Local Console</p>
</main>
<script>
(function(){
var RETRY = 3; // seconds between automatic probes
var attempts = 0, checking = false, done = false, timer = null;
var tel = document.getElementById('telemetry');
var chipText = document.getElementById('chip-text');
var btn = document.getElementById('retry');
var btnLabel = document.getElementById('btn-label');
function pad(n){ return n < 10 ? '0' + n : '' + n; }
function say(m){ if (tel) tel.textContent = m; }
function probe(){
if (checking || done) return;
checking = true; attempts++;
if (btn) btn.disabled = true;
if (btnLabel) btnLabel.textContent = 'Checking';
say('Probing for signal · attempt ' + pad(attempts));
// Any settled response (even 401/403) means the server is reachable.
// Cache-bust + no-store so a stale SW cache can't fake a recovery.
fetch('/?_swping=' + Date.now(), { method: 'HEAD', cache: 'no-store' })
.then(restored)
.catch(function(){
checking = false;
if (btn) btn.disabled = false;
if (btnLabel) btnLabel.textContent = 'Reconnect';
countdown(RETRY);
});
}
function restored(){
done = true;
document.body.classList.add('online');
if (chipText) chipText.textContent = 'Link Restored';
if (btnLabel) btnLabel.textContent = 'Reconnecting';
say('Signal acquired — reloading');
setTimeout(function(){ location.reload(); }, 750);
}
function countdown(s){
if (done) return;
if (s <= 0){ probe(); return; }
say('Retrying in ' + pad(s) + 's · attempt ' + pad(attempts + 1));
timer = setTimeout(function(){ countdown(s - 1); }, 1000);
}
function probeNow(){ if (timer){ clearTimeout(timer); timer = null; } probe(); }
if (btn) btn.addEventListener('click', probeNow);
window.addEventListener('online', probeNow);
document.addEventListener('visibilitychange', function(){ if (!document.hidden) probeNow(); });
probe();
})();
</script>
</body>
</html>`;
// Install: pre-cache core shell // Install: pre-cache core shell
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
event.waitUntil( event.waitUntil(
@@ -66,17 +279,17 @@ self.addEventListener('fetch', (event) => {
return; return;
} }
// Navigation: network-only (page requires auth, no useful offline fallback) // Navigation: network-first with branded, self-healing offline fallback
if (event.request.mode === 'navigate') { if (event.request.mode === 'navigate') {
event.respondWith( event.respondWith(
fetch(event.request).catch(() => fetch(event.request).catch(() =>
new Response( new Response(OFFLINE_HTML, {
'<html><body style="font-family:system-ui;text-align:center;padding:60px 20px;background:#1a1a1a;color:#ccc">' + status: 503,
'<h2>LED Grab</h2><p>Cannot reach the server. Check that it is running and you are on the same network.</p>' + headers: {
'<button onclick="location.reload()" style="margin-top:20px;padding:10px 24px;border-radius:8px;border:none;background:#4CAF50;color:#fff;font-size:1rem;cursor:pointer">Retry</button>' + 'Content-Type': 'text/html; charset=utf-8',
'</body></html>', 'Cache-Control': 'no-store',
{ status: 503, headers: { 'Content-Type': 'text/html' } } },
) })
) )
); );
return; return;
@@ -84,6 +84,14 @@
<small class="input-hint" style="display:none" id="device-url-hint" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small> <small class="input-hint" style="display:none" id="device-url-hint" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
<input type="text" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required> <input type="text" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div> </div>
<div class="form-group" id="device-mqtt-source-group" style="display: none;">
<div class="label-row">
<label for="device-mqtt-source" data-i18n="device.mqtt_source">MQTT Broker:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.mqtt_source.hint">Which MQTT broker this device publishes to. Manage brokers under Integrations → MQTT Sources. Leave unset to use the first available broker.</small>
<select id="device-mqtt-source"></select>
</div>
<div class="form-group" id="device-serial-port-group" style="display: none;"> <div class="form-group" id="device-serial-port-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label for="device-serial-port" id="device-serial-port-label" data-i18n="device.serial_port">Serial Port:</label> <label for="device-serial-port" id="device-serial-port-label" data-i18n="device.serial_port">Serial Port:</label>
@@ -53,6 +53,15 @@
<select id="settings-serial-port"></select> <select id="settings-serial-port"></select>
</div> </div>
<div class="form-group" id="settings-mqtt-source-group" style="display: none;">
<div class="label-row">
<label for="settings-mqtt-source" data-i18n="device.mqtt_source">MQTT Broker:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.mqtt_source.hint">Which MQTT broker this device publishes to. Manage brokers under Integrations → MQTT Sources. Leave unset to use the first available broker.</small>
<select id="settings-mqtt-source"></select>
</div>
<div class="form-group" id="settings-ble-family-group" style="display: none;"> <div class="form-group" id="settings-ble-family-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label for="settings-ble-family" data-i18n="device.ble.family">Protocol Family:</label> <label for="settings-ble-family" data-i18n="device.ble.family">Protocol Family:</label>
+17 -13
View File
@@ -101,16 +101,15 @@ class _WNDCLASS(ctypes.Structure):
] ]
class _MSG(ctypes.Structure): # Use the stdlib wintypes.MSG (rather than a project-local _MSG) so the
_fields_ = [ # POINTER(MSG) type is shared with any other module that binds
("hwnd", wintypes.HWND), # user32.GetMessageW.argtypes — argtypes is a global on the cached DLL
("message", wintypes.UINT), # handle, and two modules binding it with different POINTER classes for
("wParam", wintypes.WPARAM), # the same C function fight each other (last writer wins, the other one's
("lParam", wintypes.LPARAM), # byref() then trips Python 3.13's strict argtype check). PlatformDetector's
("time", wintypes.DWORD), # display-power monitor binds with POINTER(wintypes.MSG); aligning here
("pt_x", wintypes.LONG), # means whichever loads last produces the same binding.
("pt_y", wintypes.LONG), _MSG = wintypes.MSG
]
def _bind_winapi() -> None: def _bind_winapi() -> None:
@@ -133,18 +132,23 @@ def _bind_winapi() -> None:
] ]
user32.DefWindowProcW.restype = LRESULT user32.DefWindowProcW.restype = LRESULT
# Pin the MSG pointer type once and reuse the same class object on all
# three prototypes — Python 3.13 ctypes rejects mismatched POINTER(_MSG)
# caches across argtypes, even though they look identical.
LPMSG = ctypes.POINTER(_MSG)
user32.GetMessageW.argtypes = [ user32.GetMessageW.argtypes = [
ctypes.POINTER(_MSG), LPMSG,
wintypes.HWND, wintypes.HWND,
wintypes.UINT, wintypes.UINT,
wintypes.UINT, wintypes.UINT,
] ]
user32.GetMessageW.restype = wintypes.BOOL user32.GetMessageW.restype = wintypes.BOOL
user32.TranslateMessage.argtypes = [ctypes.POINTER(_MSG)] user32.TranslateMessage.argtypes = [LPMSG]
user32.TranslateMessage.restype = wintypes.BOOL user32.TranslateMessage.restype = wintypes.BOOL
user32.DispatchMessageW.argtypes = [ctypes.POINTER(_MSG)] user32.DispatchMessageW.argtypes = [LPMSG]
user32.DispatchMessageW.restype = LRESULT user32.DispatchMessageW.restype = LRESULT
user32.PostMessageW.argtypes = [ user32.PostMessageW.argtypes = [
+103 -1
View File
@@ -47,6 +47,13 @@ def output_target_store(_route_db):
return OutputTargetStore(_route_db) return OutputTargetStore(_route_db)
@pytest.fixture
def mqtt_source_store(_route_db):
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
return MQTTSourceStore(_route_db)
@pytest.fixture @pytest.fixture
def processor_manager(): def processor_manager():
"""A mock ProcessorManager — avoids real hardware.""" """A mock ProcessorManager — avoids real hardware."""
@@ -60,7 +67,7 @@ def processor_manager():
@pytest.fixture @pytest.fixture
def client(device_store, output_target_store, processor_manager): def client(device_store, output_target_store, processor_manager, mqtt_source_store):
app = _make_app() app = _make_app()
# Override auth to always pass # Override auth to always pass
@@ -72,6 +79,7 @@ def client(device_store, output_target_store, processor_manager):
app.dependency_overrides[deps.get_device_store] = lambda: device_store app.dependency_overrides[deps.get_device_store] = lambda: device_store
app.dependency_overrides[deps.get_output_target_store] = lambda: output_target_store app.dependency_overrides[deps.get_output_target_store] = lambda: output_target_store
app.dependency_overrides[deps.get_processor_manager] = lambda: processor_manager app.dependency_overrides[deps.get_processor_manager] = lambda: processor_manager
app.dependency_overrides[deps.get_mqtt_store] = lambda: mqtt_source_store
return TestClient(app, raise_server_exceptions=False) return TestClient(app, raise_server_exceptions=False)
@@ -428,6 +436,100 @@ class TestWLEDSchemeInference:
assert device_store.get_device(existing.id).url == "http://10.0.0.5" assert device_store.get_device(existing.id).url == "http://10.0.0.5"
class TestMqttSourceId:
"""Regression coverage for the device ``mqtt_source_id`` field.
The store + ``device_config.MQTTConfig`` already carried the field, but
the API schema/route layer dropped it (DeviceCreate/Update/Response never
declared it, and the route never threaded it). These pin the create +
update round-trip and the referenced-source validation so it can't
silently regress.
"""
@pytest.fixture
def _stub_mqtt_validate(self, monkeypatch):
async def fake_validate(self, url): # noqa: ARG001 — provider self
return {"led_count": 60}
from ledgrab.core.devices.mqtt_provider import MQTTDeviceProvider
monkeypatch.setattr(MQTTDeviceProvider, "validate_device", fake_validate)
return fake_validate
def test_create_mqtt_device_persists_source_id(
self, client, device_store, mqtt_source_store, _stub_mqtt_validate
):
src = mqtt_source_store.create_source(name="Broker A", broker_host="192.168.1.10")
resp = client.post(
"/api/v1/devices",
json={
"name": "Living Room MQTT",
"device_type": "mqtt",
"url": "mqtt://ledgrab/device/living-room",
"led_count": 60,
"mqtt_source_id": src.id,
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["mqtt_source_id"] == src.id
assert device_store.get_device(body["id"]).mqtt_source_id == src.id
def test_create_mqtt_device_rejects_unknown_source(self, client, _stub_mqtt_validate):
resp = client.post(
"/api/v1/devices",
json={
"name": "Bad Broker Ref",
"device_type": "mqtt",
"url": "mqtt://ledgrab/device/x",
"led_count": 60,
"mqtt_source_id": "mqs_doesnotexist",
},
)
assert resp.status_code == 422, resp.text
assert "not found" in resp.json()["detail"]
def test_update_device_sets_mqtt_source_id(self, client, device_store, mqtt_source_store):
src = mqtt_source_store.create_source(name="Broker B", broker_host="10.0.0.2")
dev = device_store.create_device(
name="MQTT dev",
url="mqtt://ledgrab/device/a",
led_count=10,
device_type="mqtt",
)
resp = client.put(f"/api/v1/devices/{dev.id}", json={"mqtt_source_id": src.id})
assert resp.status_code == 200, resp.text
assert resp.json()["mqtt_source_id"] == src.id
assert device_store.get_device(dev.id).mqtt_source_id == src.id
def test_update_device_can_clear_mqtt_source(self, client, device_store, mqtt_source_store):
"""An empty string unsets the broker (back to 'first available'). The
store's None-means-skip rule means '' is a real value that persists."""
src = mqtt_source_store.create_source(name="Broker C", broker_host="10.0.0.3")
dev = device_store.create_device(
name="MQTT dev3",
url="mqtt://ledgrab/device/c",
led_count=10,
device_type="mqtt",
mqtt_source_id=src.id,
)
resp = client.put(f"/api/v1/devices/{dev.id}", json={"mqtt_source_id": ""})
assert resp.status_code == 200, resp.text
assert resp.json()["mqtt_source_id"] == ""
assert device_store.get_device(dev.id).mqtt_source_id == ""
def test_update_device_rejects_unknown_mqtt_source(self, client, device_store):
dev = device_store.create_device(
name="MQTT dev2",
url="mqtt://ledgrab/device/b",
led_count=10,
device_type="mqtt",
)
resp = client.put(f"/api/v1/devices/{dev.id}", json={"mqtt_source_id": "mqs_nope"})
assert resp.status_code == 422, resp.text
assert "not found" in resp.json()["detail"]
class TestPairThenCreateFlow: class TestPairThenCreateFlow:
"""End-to-end coverage: pair, then persist; assert the token is """End-to-end coverage: pair, then persist; assert the token is
encrypted at rest and decrypted in to_config(), and that the API encrypted at rest and decrypted in to_config(), and that the API
@@ -0,0 +1,143 @@
"""Endpoint tests for the wiring-graph router (/api/v1/graph*).
The graph router resolves stores via the ``dependencies`` getters *directly*
(not FastAPI ``Depends``), so these tests populate the ``deps._deps`` registry
rather than using ``app.dependency_overrides``. Auth stays real (conftest pins a
test API key) so the rejection path is covered too.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from ledgrab.api import dependencies as deps
from ledgrab.api.routes.graph import router
_AUTH = {"Authorization": "Bearer test-api-key-12345"}
class _Ent:
"""Minimal stand-in for a storage model exposing ``to_dict``."""
def __init__(self, data: dict):
self._data = data
def to_dict(self) -> dict:
return self._data
def _store(*entities: dict):
class _S:
def get_all(self):
return [_Ent(e) for e in entities]
return _S()
@pytest.fixture
def client(test_config, monkeypatch):
import ledgrab.config as config_mod
monkeypatch.setattr(config_mod, "config", test_config)
# Populate only the kinds under test; the rest resolve to RuntimeError and
# are gracefully treated as empty by the route's _gather_entities.
monkeypatch.setattr(
deps,
"_deps",
{
"device_store": _store({"id": "dev_1", "name": "Strip", "device_type": "wled"}),
"picture_source_store": _store({"id": "ps_1", "name": "Cap", "stream_type": "raw"}),
"color_strip_store": _store(
{
"id": "css_1",
"name": "CSS",
"source_type": "picture",
"picture_source_id": "ps_1",
}
),
"output_target_store": _store(
{
"id": "ot_1",
"name": "TV",
"target_type": "led",
"device_id": "dev_1",
"color_strip_source_id": "css_1",
}
),
},
)
app = FastAPI()
app.include_router(router)
return TestClient(app, raise_server_exceptions=False)
def test_schema_endpoint_returns_registry(client):
resp = client.get("/api/v1/graph/schema", headers=_AUTH)
assert resp.status_code == 200
data = resp.json()
assert "device" in data["kinds"]
assert any(
c["target_kind"] == "output_target" and c["field"] == "device_id"
for c in data["connections"]
)
# Bindable + nested flags must be carried through.
assert any(c["bindable"] and c["nested"] for c in data["connections"])
def test_schema_endpoint_requires_auth(client):
assert client.get("/api/v1/graph/schema").status_code in (401, 403)
def test_graph_endpoint_builds_topology(client):
data = client.get("/api/v1/graph", headers=_AUTH).json()
assert {n["id"] for n in data["nodes"]} == {"dev_1", "ps_1", "css_1", "ot_1"}
edges = {(e["from"], e["to"]) for e in data["edges"]}
assert ("dev_1", "ot_1") in edges
assert ("css_1", "ot_1") in edges
assert ("ps_1", "css_1") in edges
assert data["issues"]["broken_refs"] == []
def test_dependents_endpoint(client):
data = client.get("/api/v1/graph/dependents/color_strip_source/css_1", headers=_AUTH).json()
assert data["dependents"] == [
{"id": "ot_1", "kind": "output_target", "name": "TV", "field": "color_strip_source_id"}
]
def test_dependents_endpoint_rejects_unknown_kind(client):
resp = client.get("/api/v1/graph/dependents/bogus/x", headers=_AUTH)
assert resp.status_code == 404
def test_validate_connection_endpoint_accepts_valid(client):
resp = client.post(
"/api/v1/graph/validate-connection",
json={
"target_kind": "output_target",
"target_id": "ot_1",
"field": "color_strip_source_id",
"source_id": "css_1",
},
headers=_AUTH,
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "error": None}
def test_validate_connection_endpoint_rejects_missing_source(client):
resp = client.post(
"/api/v1/graph/validate-connection",
json={
"target_kind": "output_target",
"target_id": "ot_1",
"field": "color_strip_source_id",
"source_id": "ghost",
},
headers=_AUTH,
)
data = resp.json()
assert data["ok"] is False
assert "not found" in data["error"]
@@ -0,0 +1,225 @@
"""Tests for the aggregated /api/v1/snapshot endpoint.
The snapshot collapses the integration's per-target/per-device poll fan-out
into one response. These tests build a minimal app with the snapshot router and
override the store/manager getters, mirroring tests/api/routes/test_devices_routes.py.
Auth is left real (the conftest patches a test API key) so the rejection path is
also covered. System metrics + health run for real they read module-level
providers and the patched config, no lifespan needed.
"""
import types
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from ledgrab.api import dependencies as deps
from ledgrab.api.routes import devices as devices_mod
from ledgrab.api.routes.devices import resolve_device_brightness
from ledgrab.api.routes.snapshot import SNAPSHOT_SECTIONS, _resolve_sections, router
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.core.update.update_service import UpdateService
_AUTH = {"Authorization": "Bearer test-api-key-12345"}
_TOP_LEVEL_KEYS = (
"targets",
"target_states",
"target_metrics",
"devices",
"device_brightness",
"css_sources",
"value_sources",
"scene_presets",
"sync_clocks",
"system",
)
@pytest.fixture
def client(test_config, monkeypatch):
# Pin the global config (with its test API key) so auth is deterministic
# regardless of test ordering — other suites mutate the config singleton.
import ledgrab.config as config_mod
monkeypatch.setattr(config_mod, "config", test_config)
target_store = MagicMock()
target_store.get_all_targets.return_value = []
device_store = MagicMock()
device_store.get_all_devices.return_value = []
css_store = MagicMock()
css_store.get_all_sources.return_value = []
value_store = MagicMock()
value_store.get_all_sources.return_value = []
preset_store = MagicMock()
preset_store.get_all_presets.return_value = []
clock_store = MagicMock()
clock_store.get_all_clocks.return_value = []
clock_manager = MagicMock()
manager = MagicMock(spec=ProcessorManager)
manager.get_all_target_states.return_value = {}
manager.get_all_target_metrics.return_value = {}
update_service = MagicMock(spec=UpdateService)
update_service.get_status.return_value = {"has_update": False, "current_version": "test"}
app = FastAPI()
app.include_router(router)
app.dependency_overrides[deps.get_output_target_store] = lambda: target_store
app.dependency_overrides[deps.get_device_store] = lambda: device_store
app.dependency_overrides[deps.get_color_strip_store] = lambda: css_store
app.dependency_overrides[deps.get_value_source_store] = lambda: value_store
app.dependency_overrides[deps.get_scene_preset_store] = lambda: preset_store
app.dependency_overrides[deps.get_sync_clock_store] = lambda: clock_store
app.dependency_overrides[deps.get_sync_clock_manager] = lambda: clock_manager
app.dependency_overrides[deps.get_processor_manager] = lambda: manager
app.dependency_overrides[deps.get_update_service] = lambda: update_service
return TestClient(app, raise_server_exceptions=False)
def test_snapshot_returns_all_sections(client):
resp = client.get("/api/v1/snapshot", headers=_AUTH)
assert resp.status_code == 200
data = resp.json()
for key in _TOP_LEVEL_KEYS:
assert key in data, f"snapshot missing top-level key: {key}"
for list_key in (
"targets",
"devices",
"css_sources",
"value_sources",
"scene_presets",
"sync_clocks",
):
assert data[list_key] == []
for dict_key in ("target_states", "target_metrics", "device_brightness"):
assert data[dict_key] == {}
def test_snapshot_system_block_has_health_version(client):
data = client.get("/api/v1/snapshot", headers=_AUTH).json()
system = data["system"]
assert {"performance", "health", "update"}.issubset(system)
# health drives the coordinator's version + boot-time derivation
assert system["health"]["version"]
assert "uptime_seconds" in system["health"]
def test_snapshot_requires_auth(client):
resp = client.get("/api/v1/snapshot")
assert resp.status_code in (401, 403)
def test_snapshot_include_filters_to_requested_sections(client):
resp = client.get("/api/v1/snapshot", params={"include": "devices,system"}, headers=_AUTH)
assert resp.status_code == 200
# Only requested sections are present — excluded ones are omitted entirely.
assert set(resp.json().keys()) == {"devices", "system"}
def test_snapshot_include_rejects_unknown_section(client):
resp = client.get("/api/v1/snapshot", params={"include": "devices,bogus"}, headers=_AUTH)
assert resp.status_code == 422
assert "bogus" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# _resolve_sections — query-param parsing edge cases
# ---------------------------------------------------------------------------
def test_resolve_sections_defaults_to_all_when_empty():
assert _resolve_sections(None) == frozenset(SNAPSHOT_SECTIONS)
assert _resolve_sections("") == frozenset(SNAPSHOT_SECTIONS)
def test_resolve_sections_strips_whitespace_and_dedupes():
assert _resolve_sections("devices, system ,devices") == frozenset({"devices", "system"})
def test_resolve_sections_ignores_empty_segments():
assert _resolve_sections("devices,,system,") == frozenset({"devices", "system"})
def test_resolve_sections_is_case_sensitive():
with pytest.raises(HTTPException) as exc:
_resolve_sections("Devices")
assert exc.value.status_code == 422
# ---------------------------------------------------------------------------
# resolve_device_brightness — cached / cold-fetch / graceful-degrade paths
# ---------------------------------------------------------------------------
def _fake_device(**kw):
return types.SimpleNamespace(
id=kw.get("id", "d1"),
device_type=kw.get("device_type", "wled"),
url=kw.get("url", "http://x"),
software_brightness=kw.get("software_brightness", 42),
)
async def test_brightness_none_without_capability(monkeypatch):
monkeypatch.setattr(devices_mod, "get_device_capabilities", lambda _dt: set())
manager = MagicMock()
assert await resolve_device_brightness(_fake_device(), manager) is None
manager.find_device_state.assert_not_called()
async def test_brightness_returns_cached(monkeypatch):
monkeypatch.setattr(devices_mod, "get_device_capabilities", lambda _dt: {"brightness_control"})
manager = MagicMock()
manager.find_device_state.return_value = types.SimpleNamespace(hardware_brightness=128)
assert await resolve_device_brightness(_fake_device(), manager) == 128
async def test_brightness_active_fetch_and_caches_on_cold_cache(monkeypatch):
monkeypatch.setattr(devices_mod, "get_device_capabilities", lambda _dt: {"brightness_control"})
provider = MagicMock()
provider.get_brightness = AsyncMock(return_value=200)
monkeypatch.setattr(devices_mod, "get_provider", lambda _dt: provider)
ds = types.SimpleNamespace(hardware_brightness=None)
manager = MagicMock()
manager.find_device_state.return_value = ds
assert await resolve_device_brightness(_fake_device(), manager) == 200
assert ds.hardware_brightness == 200 # cached so the next poll is I/O-free
async def test_brightness_degrades_to_none_on_provider_error(monkeypatch):
monkeypatch.setattr(devices_mod, "get_device_capabilities", lambda _dt: {"brightness_control"})
provider = MagicMock()
provider.get_brightness = AsyncMock(side_effect=OSError("unreachable"))
monkeypatch.setattr(devices_mod, "get_provider", lambda _dt: provider)
manager = MagicMock()
manager.find_device_state.return_value = types.SimpleNamespace(hardware_brightness=None)
# A single unreachable device must not raise — it degrades to None.
assert await resolve_device_brightness(_fake_device(), manager) is None
async def test_brightness_falls_back_to_software_when_unsupported(monkeypatch):
monkeypatch.setattr(devices_mod, "get_device_capabilities", lambda _dt: {"brightness_control"})
provider = MagicMock()
provider.get_brightness = AsyncMock(side_effect=NotImplementedError)
monkeypatch.setattr(devices_mod, "get_provider", lambda _dt: provider)
manager = MagicMock()
manager.find_device_state.return_value = types.SimpleNamespace(hardware_brightness=None)
assert await resolve_device_brightness(_fake_device(software_brightness=42), manager) == 42
+266
View File
@@ -0,0 +1,266 @@
"""Unit tests for the pure wiring-graph engine (ledgrab.api.graph_schema).
These exercise reference extraction, topology building, dependents, cycle and
dangling-reference detection without booting the app or any store.
"""
from ledgrab.api.graph_schema import (
CONNECTION_SCHEMA,
ENTITY_KINDS,
build_topology,
detect_cycles,
extract_refs,
find_dependents,
schema_for_kind,
validate_connection,
would_create_cycle,
)
# ── extract_refs ────────────────────────────────────────────────────────────
def test_extract_refs_top_level():
assert extract_refs({"device_id": "dev_1"}, "device_id") == ["dev_1"]
def test_extract_refs_empty_and_missing_are_dropped():
assert extract_refs({"device_id": ""}, "device_id") == []
assert extract_refs({}, "device_id") == []
assert extract_refs({"device_id": None}, "device_id") == []
def test_extract_refs_bindable_bound_vs_unbound():
# Bound bindable serializes as a dict.
assert extract_refs(
{"brightness": {"value": 1.0, "source_id": "vs_1"}}, "brightness.source_id"
) == ["vs_1"]
# Unbound bindable serializes as a plain number → no reference.
assert extract_refs({"brightness": 0.5}, "brightness.source_id") == []
# Bound-but-empty source_id → no reference.
assert (
extract_refs({"brightness": {"value": 1.0, "source_id": ""}}, "brightness.source_id") == []
)
def test_extract_refs_list_field():
entity = {"layers": [{"source_id": "a"}, {"source_id": ""}, {"other": "x"}]}
assert extract_refs(entity, "layers[].source_id") == ["a"]
def test_extract_refs_deep_object_then_list():
entity = {"calibration": {"lines": [{"picture_source_id": "p1"}, {"picture_source_id": "p2"}]}}
assert extract_refs(entity, "calibration.lines[].picture_source_id") == ["p1", "p2"]
def test_extract_refs_nested_object_none_is_safe():
assert extract_refs({"settings": None}, "settings.pattern_template_id") == []
assert extract_refs(
{"settings": {"pattern_template_id": "pt_1"}}, "settings.pattern_template_id"
) == ["pt_1"]
# ── registry consistency ─────────────────────────────────────────────────────
def test_registry_kinds_are_known():
for cf in CONNECTION_SCHEMA:
assert cf.target_kind in ENTITY_KINDS, cf.target_kind
assert cf.source_kind in ENTITY_KINDS, cf.source_kind
def test_registry_has_no_duplicate_target_field_pairs():
pairs = [(cf.target_kind, cf.field) for cf in CONNECTION_SCHEMA]
assert len(pairs) == len(set(pairs)), "duplicate (target_kind, field) in CONNECTION_SCHEMA"
def test_schema_for_kind_filters_by_referrer():
fields = {cf.field for cf in schema_for_kind("automation")}
assert fields == {"scene_preset_id", "deactivation_scene_preset_id"}
# ── build_topology ───────────────────────────────────────────────────────────
def _sample_entities():
return {
"device": [{"id": "dev_1", "name": "Strip", "device_type": "wled"}],
"picture_source": [{"id": "ps_1", "name": "Cap", "stream_type": "raw"}],
"color_strip_source": [
{"id": "css_1", "name": "CSS", "source_type": "picture", "picture_source_id": "ps_1"}
],
"output_target": [
{
"id": "ot_1",
"name": "TV",
"target_type": "led",
"device_id": "dev_1",
"color_strip_source_id": "css_1",
}
],
}
def test_build_topology_nodes_and_edges():
topo = build_topology(_sample_entities())
assert {n["id"] for n in topo["nodes"]} == {"dev_1", "ps_1", "css_1", "ot_1"}
edge_set = {(e["from"], e["to"], e["field"]) for e in topo["edges"]}
assert ("ps_1", "css_1", "picture_source_id") in edge_set
assert ("dev_1", "ot_1", "device_id") in edge_set
assert ("css_1", "ot_1", "color_strip_source_id") in edge_set
assert topo["issues"]["orphans"] == []
assert topo["issues"]["broken_refs"] == []
assert topo["issues"]["cycles"] == []
def test_build_topology_flags_orphan_and_broken_ref():
entities = _sample_entities()
entities["sync_clock"] = [{"id": "clk_1", "name": "Lonely"}] # no edges → orphan
entities["color_strip_source"][0]["audio_source_id"] = "ghost" # dangling
topo = build_topology(entities)
assert "clk_1" in topo["issues"]["orphans"]
broken = topo["issues"]["broken_refs"]
assert {"ref": "ghost", "by": "css_1", "field": "audio_source_id"} in broken
# The dangling ref must NOT appear as a real edge.
assert all(e["from"] != "ghost" for e in topo["edges"])
def test_build_topology_detects_cycle():
entities = {
"value_source": [
{"id": "vs_1", "name": "A", "source_type": "static", "value_source_id": "vs_2"},
{"id": "vs_2", "name": "B", "source_type": "static", "value_source_id": "vs_1"},
]
}
topo = build_topology(entities)
assert set(topo["issues"]["cycles"]) == {"vs_1", "vs_2"}
# ── detect_cycles ────────────────────────────────────────────────────────────
def test_detect_cycles_no_false_positive_on_diamond():
# a→b, a→c, b→d, c→d (DAG)
edges = [
{"from": "a", "to": "b"},
{"from": "a", "to": "c"},
{"from": "b", "to": "d"},
{"from": "c", "to": "d"},
]
assert detect_cycles(edges) == set()
def test_detect_cycles_marks_only_cycle_members():
# x→a→b→a (a,b in cycle; x is not)
edges = [
{"from": "x", "to": "a"},
{"from": "a", "to": "b"},
{"from": "b", "to": "a"},
]
assert detect_cycles(edges) == {"a", "b"}
# ── would_create_cycle ───────────────────────────────────────────────────────
def test_would_create_cycle_detects_back_edge_and_self():
edges = [{"from": "a", "to": "b"}] # b depends on a (a→b)
# Wiring a to consume b (edge b→a) closes the a→b→a loop.
assert would_create_cycle(edges, source_id="b", target_id="a") is True
# Wiring an unrelated pair is fine.
assert would_create_cycle(edges, source_id="a", target_id="c") is False
# Self-reference is always a cycle.
assert would_create_cycle(edges, source_id="z", target_id="z") is True
# ── find_dependents ──────────────────────────────────────────────────────────
def test_find_dependents_returns_referrers():
deps = find_dependents(_sample_entities(), "color_strip_source", "css_1")
assert deps == [
{"id": "ot_1", "kind": "output_target", "name": "TV", "field": "color_strip_source_id"}
]
def test_find_dependents_empty_when_unreferenced():
assert find_dependents(_sample_entities(), "color_strip_source", "css_unused") == []
# ── validate_connection ──────────────────────────────────────────────────────
def test_validate_connection_accepts_valid_edit():
ok, err = validate_connection(_sample_entities(), "output_target", "ot_1", "device_id", "dev_1")
assert ok is True
assert err is None
def test_validate_connection_detach_always_ok():
ok, err = validate_connection(_sample_entities(), "output_target", "ot_1", "device_id", "")
assert ok is True and err is None
def test_validate_connection_rejects_unknown_field():
ok, err = validate_connection(_sample_entities(), "output_target", "ot_1", "nope_id", "dev_1")
assert ok is False
assert "Unknown connection field" in err
def test_validate_connection_rejects_missing_source():
ok, err = validate_connection(_sample_entities(), "output_target", "ot_1", "device_id", "ghost")
assert ok is False
assert "not found" in err
def test_validate_connection_accepts_bindable_single_field():
# Single-level bindable slots (e.g. brightness) ARE editable — only list
# fields are rejected.
entities = {
"color_strip_source": [
{"id": "css_1", "name": "X", "source_type": "effect", "brightness": 0.5}
],
"value_source": [{"id": "vs_1", "name": "V", "source_type": "static"}],
}
ok, err = validate_connection(
entities, "color_strip_source", "css_1", "brightness.source_id", "vs_1"
)
assert ok is True
assert err is None
def test_validate_connection_rejects_list_field():
# List slots can't be cycle-checked without an element index → rejected.
entities = {
"color_strip_source": [
{
"id": "css_1",
"name": "X",
"source_type": "composite",
"layers": [{"source_id": "css_2"}],
},
{"id": "css_2", "name": "Y", "source_type": "picture"},
]
}
ok, err = validate_connection(
entities, "color_strip_source", "css_1", "layers[].source_id", "css_2"
)
assert ok is False
assert "List connection" in err
def test_validate_connection_rejects_cycle():
entities = {
"value_source": [
{"id": "vs_1", "name": "A", "source_type": "static", "value_source_id": ""},
{"id": "vs_2", "name": "B", "source_type": "static", "value_source_id": "vs_1"},
]
}
# vs_2 already depends on vs_1 (edge vs_1→vs_2). Wiring vs_1 to consume vs_2
# would close the loop.
ok, err = validate_connection(entities, "value_source", "vs_1", "value_source_id", "vs_2")
assert ok is False
assert "cycle" in err.lower()
@@ -0,0 +1,210 @@
"""Regression coverage for :class:`AutoBackupEngine` ZIP format.
Pins behavior added when auto-backups switched from raw ``.db`` snapshots to
``.zip`` archives (DB + assets), and the partial-write hardening that goes
with it (stage at ``<name>.partial`` then ``os.replace`` so a crash mid-write
never leaves a corrupt file masquerading as a valid backup).
"""
import zipfile
from pathlib import Path
from unittest.mock import patch
import pytest
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.database import Database
@pytest.fixture()
def engine_with_assets(tmp_path):
"""Engine wired to a small DB + assets dir with two sample files."""
db_path = tmp_path / "ledgrab.db"
assets_dir = tmp_path / "assets"
assets_dir.mkdir()
(assets_dir / "alert.wav").write_bytes(b"WAV_PAYLOAD_1")
(assets_dir / "ping.wav").write_bytes(b"WAV_PAYLOAD_2")
db = Database(db_path)
db.upsert("devices", "dev_1", "Living Room", {"id": "dev_1", "type": "mock"})
backup_dir = tmp_path / "backups"
engine = AutoBackupEngine(backup_dir=backup_dir, db=db, assets_dir=assets_dir)
yield engine
db.close()
def _only_backup(backup_dir: Path) -> Path:
"""Return the single .zip backup in ``backup_dir``; assert exactly one."""
zips = sorted(backup_dir.glob("*.zip"))
assert len(zips) == 1, f"expected exactly one .zip, found {[p.name for p in zips]}"
return zips[0]
def test_backup_bundles_db_and_assets(engine_with_assets):
"""Happy path: ZIP contains ``ledgrab.db`` plus every assets file."""
engine_with_assets._perform_backup_sync()
backup = _only_backup(engine_with_assets._backup_dir)
with zipfile.ZipFile(backup) as zf:
names = sorted(zf.namelist())
assert names == ["assets/alert.wav", "assets/ping.wav", "ledgrab.db"]
def test_backup_preserves_asset_bytes(engine_with_assets):
"""The asset binary inside the ZIP matches the source byte-for-byte."""
engine_with_assets._perform_backup_sync()
backup = _only_backup(engine_with_assets._backup_dir)
with zipfile.ZipFile(backup) as zf:
assert zf.read("assets/alert.wav") == b"WAV_PAYLOAD_1"
assert zf.read("assets/ping.wav") == b"WAV_PAYLOAD_2"
def test_backup_no_partial_file_left_on_success(engine_with_assets):
"""After a successful backup, the staging ``.partial`` is renamed away."""
engine_with_assets._perform_backup_sync()
leftovers = list(engine_with_assets._backup_dir.glob("*.partial"))
assert leftovers == []
def test_backup_skips_assets_when_dir_none(tmp_path):
"""``assets_dir=None`` produces a DB-only ZIP, no error."""
db_path = tmp_path / "ledgrab.db"
db = Database(db_path)
db.upsert("devices", "dev_1", "A", {"id": "dev_1"})
backup_dir = tmp_path / "backups"
try:
engine = AutoBackupEngine(backup_dir=backup_dir, db=db, assets_dir=None)
engine._perform_backup_sync()
backup = _only_backup(backup_dir)
with zipfile.ZipFile(backup) as zf:
assert zf.namelist() == ["ledgrab.db"]
finally:
db.close()
def test_backup_skips_assets_when_dir_missing(tmp_path):
"""A non-existent ``assets_dir`` is silently skipped, not an error."""
db_path = tmp_path / "ledgrab.db"
db = Database(db_path)
backup_dir = tmp_path / "backups"
try:
engine = AutoBackupEngine(
backup_dir=backup_dir, db=db, assets_dir=tmp_path / "does-not-exist"
)
engine._perform_backup_sync()
backup = _only_backup(backup_dir)
with zipfile.ZipFile(backup) as zf:
assert zf.namelist() == ["ledgrab.db"]
finally:
db.close()
def test_backup_failure_leaves_no_zip_or_partial(engine_with_assets):
"""When ``db.backup_to`` raises, neither the final .zip nor the .partial
survives. The exception propagates so the caller can log it.
"""
with patch.object(engine_with_assets._db, "backup_to", side_effect=RuntimeError("boom")):
with pytest.raises(RuntimeError, match="boom"):
engine_with_assets._perform_backup_sync()
assert list(engine_with_assets._backup_dir.glob("*.zip")) == []
assert list(engine_with_assets._backup_dir.glob("*.partial")) == []
def test_backup_cleans_stale_partial_from_previous_crash(engine_with_assets):
"""A leftover ``.partial`` from a prior crash is swept on the next run."""
stale = engine_with_assets._backup_dir
stale.mkdir(parents=True, exist_ok=True)
(stale / "ledgrab-backup-2025-01-01T000000.zip.partial").write_bytes(b"corrupt")
engine_with_assets._perform_backup_sync()
assert list(stale.glob("*.partial")) == []
# The successful backup is also present.
assert len(list(stale.glob("*.zip"))) == 1
def test_backup_skips_symlinks_in_assets_dir(tmp_path):
"""Symlinked files in ``assets_dir`` are not bundled (security guard)."""
db_path = tmp_path / "ledgrab.db"
assets_dir = tmp_path / "assets"
assets_dir.mkdir()
(assets_dir / "real.wav").write_bytes(b"REAL")
# Some test environments (e.g. unprivileged Windows) can't create
# symlinks; skip rather than fail spuriously when that's the case.
secret_target = tmp_path / "secret.bin"
secret_target.write_bytes(b"PRIVATE")
link_path = assets_dir / "linked.wav"
try:
link_path.symlink_to(secret_target)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported in this environment")
db = Database(db_path)
try:
backup_dir = tmp_path / "backups"
engine = AutoBackupEngine(backup_dir=backup_dir, db=db, assets_dir=assets_dir)
engine._perform_backup_sync()
backup = _only_backup(backup_dir)
with zipfile.ZipFile(backup) as zf:
names = zf.namelist()
assert "assets/real.wav" in names
assert "assets/linked.wav" not in names
finally:
db.close()
def test_list_backups_unions_legacy_db_and_new_zip(engine_with_assets):
"""``list_backups`` returns both legacy ``.db`` and current ``.zip`` files
so users can still restore from auto-backups written by older versions.
"""
backup_dir = engine_with_assets._backup_dir
backup_dir.mkdir(parents=True, exist_ok=True)
(backup_dir / "ledgrab-backup-2024-12-31T000000.db").write_bytes(b"legacy")
engine_with_assets._perform_backup_sync()
names = {b["filename"] for b in engine_with_assets.list_backups()}
assert any(n.endswith(".db") for n in names)
assert any(n.endswith(".zip") for n in names)
def test_prune_honors_max_across_formats(tmp_path):
"""``_prune_old_backups`` enforces ``max_backups`` across both extensions."""
db_path = tmp_path / "ledgrab.db"
db = Database(db_path)
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Two legacy .db files (older), two .zip files (newer) — max=3 should
# prune the single oldest .db and keep the rest.
import time
(backup_dir / "ledgrab-backup-2024-01-01T000000.db").write_bytes(b"a")
time.sleep(0.02)
(backup_dir / "ledgrab-backup-2024-02-01T000000.db").write_bytes(b"b")
time.sleep(0.02)
(backup_dir / "ledgrab-backup-2024-03-01T000000.zip").write_bytes(b"c")
time.sleep(0.02)
(backup_dir / "ledgrab-backup-2024-04-01T000000.zip").write_bytes(b"d")
try:
engine = AutoBackupEngine(backup_dir=backup_dir, db=db, assets_dir=None)
engine._settings["max_backups"] = 3
engine._prune_old_backups()
remaining = sorted(p.name for p in engine._iter_backup_files())
assert remaining == [
"ledgrab-backup-2024-02-01T000000.db",
"ledgrab-backup-2024-03-01T000000.zip",
"ledgrab-backup-2024-04-01T000000.zip",
]
finally:
db.close()
+78
View File
@@ -0,0 +1,78 @@
"""Tests for the request access-log middleware (token-label attribution).
The middleware emits one structured ``http_request`` line per request, tagged
with the friendly label of the API token used (from ``auth.api_keys``) so
traffic can be attributed to a specific client. These tests capture the
``main.logger`` calls to assert the label is recorded and that no-auth
endpoints fall back to ``"unauthenticated"``.
"""
import pytest
@pytest.fixture
def client_and_logs(authenticated_client, monkeypatch):
"""authenticated_client plus a captured list of (event, kwargs) log calls."""
import ledgrab.main as main_mod
calls: list[tuple[str, dict]] = []
class _FakeLogger:
def info(self, event, **kw):
calls.append((event, kw))
# main.logger is shared; keep other levels as harmless no-ops.
def debug(self, *a, **k):
pass
def warning(self, *a, **k):
pass
def error(self, *a, **k):
pass
monkeypatch.setattr(main_mod, "logger", _FakeLogger())
return authenticated_client, calls
def _http_request_logs(calls):
return [kw for event, kw in calls if event == "http_request"]
def test_access_log_records_token_label_for_authed_request(client_and_logs):
client, calls = client_and_logs
# /openapi.json requires auth but needs no initialized store, so it returns
# 200 even without app lifespan — and exercises the auth → label path.
resp = client.get("/openapi.json")
assert resp.status_code == 200
logs = _http_request_logs(calls)
assert logs, "no http_request access log emitted"
entry = logs[-1]
assert entry["method"] == "GET"
assert entry["path"] == "/openapi.json"
assert entry["status"] == 200
assert entry["token"] == "test" # label of the configured test API key
assert "duration_ms" in entry
def test_access_log_marks_unauthenticated_for_no_auth_endpoint(client_and_logs):
client, calls = client_and_logs
# /health has no auth dependency, so no label is set on request.state.
resp = client.get("/health")
assert resp.status_code == 200
logs = _http_request_logs(calls)
assert logs
assert logs[-1]["token"] == "unauthenticated"
def test_access_log_never_contains_the_token_secret(client_and_logs):
client, calls = client_and_logs
client.get("/openapi.json")
for _event, kw in calls:
assert "test-api-key-12345" not in repr(kw), "token secret leaked into logs"
+33 -1
View File
@@ -12,7 +12,12 @@ import signal
import threading import threading
from types import SimpleNamespace from types import SimpleNamespace
from ledgrab.__main__ import _install_signal_handlers, _request_shutdown from ledgrab.__main__ import (
_build_server,
_install_signal_handlers,
_request_shutdown,
)
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
def test_request_shutdown_sets_should_exit() -> None: def test_request_shutdown_sets_should_exit() -> None:
@@ -21,6 +26,33 @@ def test_request_shutdown_sets_should_exit() -> None:
assert server.should_exit is True assert server.should_exit is True
def test_build_server_bounds_graceful_shutdown() -> None:
"""uvicorn defaults ``timeout_graceful_shutdown`` to ``None`` (wait
forever). A lingering events WebSocket then blocks ``Server.shutdown()``
from ever reaching the lifespan shutdown, so LED targets are never
stopped and the process can't exit. The bound is what guarantees the
lifespan shutdown runs assert we never regress to the unbounded default.
"""
fake_config = SimpleNamespace(
server=SimpleNamespace(host="127.0.0.1", port=8080, log_level="INFO")
)
server = _build_server(fake_config) # type: ignore[arg-type]
assert server.config.timeout_graceful_shutdown == GRACEFUL_SHUTDOWN_TIMEOUT
assert server.config.timeout_graceful_shutdown is not None
assert server.config.timeout_graceful_shutdown > 0
def test_graceful_shutdown_timeout_fits_os_budget() -> None:
"""The graceful wait runs BEFORE the lifespan's own ~16 s shutdown budget,
and OS shutdown gives the whole process only ~20 s before a force-kill.
Keep the bound small so target restore + DB checkpoint still fit.
"""
assert isinstance(GRACEFUL_SHUTDOWN_TIMEOUT, int)
assert 0 < GRACEFUL_SHUTDOWN_TIMEOUT <= 5
def test_install_signal_handlers_installs_for_known_signals() -> None: def test_install_signal_handlers_installs_for_known_signals() -> None:
"""Tray path runs uvicorn on a background thread, so our handlers must """Tray path runs uvicorn on a background thread, so our handlers must
actually survive verify each catchable signal is replaced. actually survive verify each catchable signal is replaced.