Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aeda935f1 | |||
| a5effba553 |
+22
-40
@@ -1,72 +1,54 @@
|
||||
## v0.8.0 (2026-05-28)
|
||||
## v0.8.1 (2026-05-28)
|
||||
|
||||
### User-facing changes
|
||||
|
||||
#### Features
|
||||
|
||||
##### Android TV — production-readiness pass
|
||||
##### Multi-broker MQTT devices
|
||||
|
||||
- **Security:** per-install random API key (persisted, threaded into the embedded server via env, embedded in the pairing QR as a URL-fragment so it never reaches HTTP logs); root-shell injection eliminated via POSIX-quoted `runAsRoot(argv)` overload; broadcast receivers locked to the app package; release builds refuse to silently sign with the debug keystore; crash log retention capped at 10 entries
|
||||
- **Performance:** single reusable RGBA buffer in `ScreenCapture` / `RootScreenrecord` (eliminates ~15 MB/s GC churn at 30 fps); frame pacer switched to `elapsedRealtimeNanos` with catch-up accumulator (fixes ~30.3 fps drift); capture dimensions derived from source aspect ratio so non-16:9 displays aren't squashed; QR bitmap cached by URL
|
||||
- **Compatibility:** compileSdk/targetSdk → 35 (Play Store requirement); armeabi-v7a build path; foreground service type declared as `mediaProjection|specialUse` with proper `ServiceCompat.startForeground` promotion; Ethernet > Wi-Fi > VPN > cellular selection in `NetworkUtils`; Android 15 predictive-back via `enableOnBackInvokedCallback`; splash screen API hides Chaquopy cold-start delay
|
||||
- **UI/UX:** all hardcoded English strings localised across en/ru/zh; monochrome notification icon; 320×180 TV banner; ViewStub-based running panel; pulse animator on Running dot; "Starting…" button while probing root; autostart checkbox hidden on unrooted devices
|
||||
- **Lifecycle hardening:** `processLock` serialises EOF respawn vs `stop()` to prevent orphaned screenrecord; publish-before-start under `@Synchronized` in `CaptureService.restartRootPipeline` closes the orphan window during watchdog restarts; watchdog give-up bound corrected ([ef1f9ea](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ef1f9ea))
|
||||
- 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"
|
||||
- `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))
|
||||
|
||||
##### Backup format — bundled DB + assets ZIP
|
||||
##### Schema-driven wiring-graph editor
|
||||
|
||||
- Auto-backups now produce a `.zip` containing `ledgrab.db` plus every file from the assets directory under `assets/` — matching the manual `GET /api/v1/system/backup` download. Restore accepts both `.zip` and legacy `.db` interchangeably
|
||||
- Partial-write hardening: writes stage to `<name>.partial` then `os.replace` into place — a crash mid-write never leaves a corrupt backup masquerading as valid. Stale `.partial` files from prior crashes are swept on the next run
|
||||
- Symlinks inside the assets directory are skipped (security guard against link targets outside the dir)
|
||||
- Backups over 500 MB log a warning so operators notice unbounded asset growth before disk fills up
|
||||
- `restart.py` redirects spawned restart script stdout/stderr to `restart.log` and bails out early if the script is missing — silent failures used to vanish into a detached child ([85da2e5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/85da2e5))
|
||||
- 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))
|
||||
|
||||
##### Spectrum-aperture icon set
|
||||
##### Aggregated snapshot endpoint
|
||||
|
||||
- Regenerated icon family from a single Pillow script: rounded-square aperture traced by a continuous RGB color-wheel stroke over a vignette canvas with chromatic bloom. 4× supersampled then downsampled per output for crispness
|
||||
- New 256 px transparent-background **tray variant** — 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 master (fixes the dark-square halo on light Windows themes)
|
||||
- Maskable 512 variant safe-area padded for PWA round-crops ([3645216](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3645216))
|
||||
- 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))
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- **Notification sound dropdowns:** both the per-app override list and the main row now always render the EntitySelect (was silently inert before any sound assets were registered) and offer "no sound" as a first-class option via `allowNone` ([1f95993](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1f95993))
|
||||
- **CSS editor:** `notification_sound` and `notification_volume` are now persisted on save — they were silently dropped from the payload before ([66b85b0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/66b85b0))
|
||||
- **Python 3.13 ctypes:** Win32 message-pump prototypes (`GetMessageW` / `TranslateMessage` / `DispatchMessageW`) now share a single `LPMSG = POINTER(wintypes.MSG)` class across `WindowsShutdownGuard` and `PlatformDetector` — fixes the `expected LP_MSG instance instead of pointer to _MSG` error and the resulting shutdown-guard / display-power-monitor failure on 3.13 ([e4d24a0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4d24a0), [0d840ad](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0d840ad))
|
||||
- **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))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### CI/Build
|
||||
#### Backend
|
||||
|
||||
- `release.yml` now creates the Gitea release as a **draft** and only flips `draft=false` once every build job (Windows, Linux, Docker) has uploaded its artifacts and sha256 sidecars — users never see a release page that's missing assets, which would have broken the in-app updater ([bc42604](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bc42604))
|
||||
- **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))
|
||||
- **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))
|
||||
- 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))
|
||||
|
||||
#### Refactoring
|
||||
#### Frontend
|
||||
|
||||
- **Shared API client + automations registry (audit M7, H8):** new `core/api-client.ts` wraps `fetchWithAuth` with typed `apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete`; 35 feature/core files migrated. FastAPI validation-array detail unwrap hardened. Automations editor's two hand-rolled `RuleType` dispatch ladders converted to `Record<RuleType, ...>` registries with an import-time exhaustiveness check ([bb3a316](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bb3a316))
|
||||
- **types.ts split (audit H6):** 1140 LOC `types.ts` split into 18 per-entity files under `types/`, original file kept as a pure re-export barrel — 102 type exports preserved with no import sites changed ([49c35a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49c35a2))
|
||||
- Service-worker refresh for the new bundle ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
||||
|
||||
#### Documentation
|
||||
#### Tests
|
||||
|
||||
- `REVIEW_RECONCILE_NOTES.md` — design doc for the dashboard innerHTML reconciliation work: bug-class analysis, latent-site inventory, decision ladder (helper / hand-rolled cells / Lit), and recommendation to migrate polling-heavy modules to Lit with `entity-events.ts` tab reconciliation sequenced first ([10eb24b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/10eb24b))
|
||||
- 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))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits (11)</summary>
|
||||
<summary>All Commits (1)</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
| ---- | ------- | ------ |
|
||||
| [0d840ad](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0d840ad) | fix(ctypes): share wintypes.MSG with platform_detector to avoid argtype races | alexei.dolgolyov |
|
||||
| [1f95993](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1f95993) | fix(notification): allow clearing the sound on per-app overrides and main row | alexei.dolgolyov |
|
||||
| [10eb24b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/10eb24b) | docs: dashboard innerHTML reconciliation review notes | alexei.dolgolyov |
|
||||
| [66b85b0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/66b85b0) | fix(css-editor): persist notification_sound + notification_volume | alexei.dolgolyov |
|
||||
| [bc42604](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bc42604) | ci(release): publish release only after every build job uploads assets | alexei.dolgolyov |
|
||||
| [3645216](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3645216) | feat(icons): spectrum aperture icon set + dedicated tray variant | alexei.dolgolyov |
|
||||
| [85da2e5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/85da2e5) | feat(backup): bundle assets in ZIP + partial-write hardening + restart log | alexei.dolgolyov |
|
||||
| [e4d24a0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4d24a0) | fix(ctypes): pin LPMSG across MSG-pump prototypes for Python 3.13 | alexei.dolgolyov |
|
||||
| [bb3a316](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bb3a316) | refactor(frontend): shared API client + automations registry (audit M7, H8) | alexei.dolgolyov |
|
||||
| [49c35a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49c35a2) | refactor(frontend): split types.ts into 18 per-entity files (audit H6) | alexei.dolgolyov |
|
||||
| [ef1f9ea](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ef1f9ea) | feat(android): production-readiness pass — security, perf, compat, UI/UX | alexei.dolgolyov |
|
||||
| [a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba) | feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -201,9 +201,19 @@ caller off the legacy path, then delete it.
|
||||
- [x] Field on `device_config.MQTTConfig`
|
||||
- [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()`
|
||||
- [x] Provider threads `mqtt_manager` via `ProviderDeps`
|
||||
- [ ] Device editor: MQTT source picker shown for `device_type=mqtt` *(UI still
|
||||
pending — backend accepts the field, but the device-create form doesn't
|
||||
expose it yet)*
|
||||
- [x] Device editor: MQTT source picker shown for `device_type=mqtt`. Turned
|
||||
out the API layer was *also* missing it (the TODO's "backend accepts the
|
||||
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`
|
||||
|
||||
@@ -213,8 +223,11 @@ caller off the legacy path, then delete it.
|
||||
### Phase 6 — `api/routes/system.py`
|
||||
|
||||
- [x] Replace integration status with `mqtt_manager.get_all_sources_status()`
|
||||
- [ ] Update frontend dashboard payload (MQTT widget now expects a list of
|
||||
sources instead of a single `enabled`/`connected` pair — surface in UI)
|
||||
- [x] Update frontend dashboard payload (MQTT widget now expects a list of
|
||||
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
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ android {
|
||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||
// sideload updates silently refused to install.
|
||||
versionCode = ledgrabVersionCode
|
||||
versionName = "0.8.0"
|
||||
versionName = "0.8.1"
|
||||
|
||||
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
||||
// ABI in only when the matching pydantic-core wheel is on disk —
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ledgrab"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
authors = [
|
||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
# In dev (running from source without `pip install -e .`) and on Android
|
||||
# (Chaquopy embeds the source directly with no dist-info), we additionally
|
||||
# read pyproject.toml so the version is always correct without manual sync.
|
||||
_FALLBACK_VERSION = "0.8.0"
|
||||
_FALLBACK_VERSION = "0.8.1"
|
||||
|
||||
|
||||
def _read_pyproject_version() -> str | None:
|
||||
|
||||
@@ -39,8 +39,9 @@ _fix_embedded_tcl_paths()
|
||||
|
||||
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.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT # noqa: E402
|
||||
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
|
||||
from ledgrab.utils import setup_logging, get_logger # noqa: E402
|
||||
from ledgrab.utils.platform import is_windows # noqa: E402
|
||||
@@ -108,17 +109,28 @@ def _check_port(host: str, port: int) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = get_config()
|
||||
_check_port(config.server.host, config.server.port)
|
||||
def _build_server(config: Config) -> uvicorn.Server:
|
||||
"""Construct the uvicorn Server with a bounded graceful-shutdown timeout.
|
||||
|
||||
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(
|
||||
"ledgrab.main:app",
|
||||
host=config.server.host,
|
||||
port=config.server.port,
|
||||
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)
|
||||
|
||||
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
|
||||
@@ -165,9 +177,11 @@ def main() -> None:
|
||||
tray.run()
|
||||
|
||||
# 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
|
||||
# cut the DB checkpoint short on a slow disk.
|
||||
server_thread.join(timeout=20)
|
||||
# Budget: the graceful-shutdown wait (GRACEFUL_SHUTDOWN_TIMEOUT) runs
|
||||
# first, then the lifespan's own ~16 s shutdown (target restore + DB
|
||||
# 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:
|
||||
guard.stop()
|
||||
else:
|
||||
|
||||
@@ -84,6 +84,8 @@ def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) ->
|
||||
"LEDGRAB_AUTH__API_KEYS."
|
||||
)
|
||||
|
||||
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
|
||||
|
||||
uv_config = uvicorn.Config(
|
||||
"ledgrab.main:app",
|
||||
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(),
|
||||
# No uvloop/httptools on Android — use pure-Python 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
|
||||
|
||||
@@ -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.pattern_templates import router as pattern_templates_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.include_router(system_router)
|
||||
@@ -66,5 +68,7 @@ router.include_router(audio_processing_templates_router)
|
||||
router.include_router(audio_filters_router)
|
||||
router.include_router(pattern_templates_router)
|
||||
router.include_router(preferences_router)
|
||||
router.include_router(snapshot_router)
|
||||
router.include_router(graph_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -80,6 +80,7 @@ def verify_api_key(
|
||||
if not config.auth.api_keys:
|
||||
# No keys configured — allow loopback only.
|
||||
if _is_loopback(client_host):
|
||||
request.state.auth_label = "anonymous"
|
||||
return "anonymous"
|
||||
# Allow caller to authenticate explicitly even without configured keys?
|
||||
# No — there are no keys to compare against. Reject.
|
||||
@@ -123,6 +124,9 @@ def verify_api_key(
|
||||
# Log successful authentication
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -13,6 +13,7 @@ from ledgrab.core.devices.led_client import (
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_device_store,
|
||||
get_mqtt_store,
|
||||
get_output_target_store,
|
||||
get_processor_manager,
|
||||
)
|
||||
@@ -33,10 +34,13 @@ from ledgrab.api.schemas.devices import (
|
||||
)
|
||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||
from ledgrab.storage import DeviceStore
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.url_scheme import infer_http_scheme
|
||||
|
||||
from ._mqtt_validation import validate_mqtt_source_exists
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -105,6 +109,7 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
gamesense_device_type=device.gamesense_device_type,
|
||||
ble_family=device.ble_family,
|
||||
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,
|
||||
group_device_ids=device.group_device_ids,
|
||||
group_mode=device.group_mode,
|
||||
@@ -124,11 +129,13 @@ async def create_device(
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Create and attach a new LED device."""
|
||||
try:
|
||||
device_type = device_data.device_type
|
||||
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 ──
|
||||
if device_type == "group":
|
||||
@@ -287,6 +294,7 @@ async def create_device(
|
||||
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
|
||||
ble_family=device_data.ble_family 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_mode=group_mode,
|
||||
)
|
||||
@@ -543,12 +551,14 @@ async def update_device(
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Update device information."""
|
||||
try:
|
||||
# Group-specific validation before applying update
|
||||
existing = store.get_device(device_id)
|
||||
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:
|
||||
# * 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,
|
||||
ble_family=update_data.ble_family,
|
||||
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_mode=update_data.group_mode,
|
||||
icon=update_data.icon,
|
||||
@@ -669,6 +680,10 @@ async def update_device(
|
||||
fire_entity_event("device", "updated", device_id)
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -777,6 +792,32 @@ async def ping_device(
|
||||
# ===== 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"])
|
||||
async def get_device_brightness(
|
||||
device_id: str,
|
||||
|
||||
@@ -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.storage.base_store import EntityNotFoundError
|
||||
|
||||
from ._mqtt_validation import validate_mqtt_source_exists
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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(
|
||||
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
|
||||
)
|
||||
@@ -333,7 +325,7 @@ async def create_target(
|
||||
case Z2MLightOutputTargetCreate():
|
||||
if data.source_kind == "color_vs":
|
||||
_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(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
@@ -540,7 +532,7 @@ async def update_target(
|
||||
)
|
||||
_validate_color_value_source(value_source_store, effective_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_id,
|
||||
name=data.name,
|
||||
|
||||
@@ -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
|
||||
@@ -131,6 +131,11 @@ class DeviceCreate(BaseModel):
|
||||
None,
|
||||
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(
|
||||
None, description="Default color strip processing template ID"
|
||||
)
|
||||
@@ -217,6 +222,9 @@ class DeviceUpdate(BaseModel):
|
||||
ble_govee_key: str | None = Field(
|
||||
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(
|
||||
None, description="Default color strip processing template ID"
|
||||
)
|
||||
@@ -436,6 +444,9 @@ class DeviceResponse(BaseModel):
|
||||
ble_govee_key: str = Field(
|
||||
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="", description="Default color strip processing template ID"
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ def main():
|
||||
|
||||
import uvicorn
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
|
||||
|
||||
config = get_config()
|
||||
uvicorn.run(
|
||||
@@ -22,7 +23,14 @@ def main():
|
||||
host=config.server.host,
|
||||
port=config.server.port,
|
||||
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,
|
||||
# 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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Awaitable
|
||||
@@ -74,7 +75,7 @@ config = get_config()
|
||||
|
||||
# The shutdown-complete signal is owned by a leaf module so ``__main__``
|
||||
# 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:
|
||||
@@ -577,6 +578,33 @@ async def _security_headers(request: Request, call_next):
|
||||
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 ────────────────────────────────────────────
|
||||
# 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
|
||||
@@ -645,5 +673,13 @@ if __name__ == "__main__":
|
||||
host=config.server.host,
|
||||
port=config.server.port,
|
||||
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
|
||||
# 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,
|
||||
)
|
||||
|
||||
@@ -16,3 +16,15 @@ they release Windows / unblock only once cleanup is genuinely done.
|
||||
import threading
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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) ── */
|
||||
|
||||
.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)));
|
||||
}
|
||||
|
||||
/* 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 ── */
|
||||
|
||||
.graph-edge {
|
||||
@@ -630,6 +657,25 @@ html:has(#tab-graph.active) {
|
||||
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 */
|
||||
.graph-edge-picture { stroke: #42A5F5; color: #42A5F5; }
|
||||
.graph-edge-colorstrip { stroke: #66BB6A; color: #66BB6A; }
|
||||
@@ -788,6 +834,17 @@ html:has(#tab-graph.active) {
|
||||
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 ── */
|
||||
|
||||
.graph-node.search-match .graph-node-body {
|
||||
@@ -1001,6 +1058,33 @@ html:has(#tab-graph.active) {
|
||||
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 {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -207,7 +207,7 @@ import {
|
||||
import {
|
||||
loadGraphEditor,
|
||||
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
|
||||
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
|
||||
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology,
|
||||
graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow,
|
||||
} from './features/graph-editor.ts';
|
||||
|
||||
@@ -625,6 +625,8 @@ Object.assign(window, {
|
||||
graphZoomIn,
|
||||
graphZoomOut,
|
||||
graphRelayout,
|
||||
graphShowIssues,
|
||||
graphExportTopology,
|
||||
graphToggleFullscreen,
|
||||
graphAddEntity,
|
||||
toggleToolbarOverflow,
|
||||
|
||||
@@ -3,12 +3,65 @@
|
||||
* 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 {
|
||||
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
||||
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
||||
} 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 ────────────────────────────────────────────────────── */
|
||||
|
||||
interface ConnectionEntry {
|
||||
@@ -19,6 +72,13 @@ interface ConnectionEntry {
|
||||
endpoint?: string;
|
||||
cache?: { invalidate(): void };
|
||||
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 {
|
||||
@@ -61,26 +121,26 @@ const CONNECTION_MAP: ConnectionEntry[] = [
|
||||
// Output targets
|
||||
{ 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: '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 },
|
||||
|
||||
// Automations
|
||||
{ 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 },
|
||||
|
||||
// ── BindableFloat value source edges (CSS properties) ──
|
||||
{ targetKind: 'color_strip_source', field: 'smoothing.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'sensitivity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'intensity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'scale.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'speed.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'wind_strength.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'temperature_influence.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'sound_volume.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'timeout.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
// ── BindableFloat value source edges (CSS properties) — drag-editable ──
|
||||
{ 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', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: 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', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: 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', 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', 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', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: 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', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
|
||||
// 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) ──
|
||||
{ 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 },
|
||||
@@ -97,12 +157,23 @@ const CONNECTION_MAP: ConnectionEntry[] = [
|
||||
{ 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.
|
||||
*/
|
||||
export function isEditableEdge(field: string): boolean {
|
||||
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[] {
|
||||
return CONNECTION_MAP.filter(c =>
|
||||
!c.nested &&
|
||||
_isEditable(c) &&
|
||||
c.targetKind === targetKind &&
|
||||
c.sourceKind === sourceKind &&
|
||||
(!edgeType || c.edgeType === edgeType)
|
||||
@@ -124,7 +195,7 @@ export function findConnection(targetKind: string, sourceKind: string, edgeType?
|
||||
*/
|
||||
export function getCompatibleInputs(sourceKind: string): CompatibleInput[] {
|
||||
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 }));
|
||||
}
|
||||
|
||||
@@ -132,7 +203,7 @@ export function getCompatibleInputs(sourceKind: string): CompatibleInput[] {
|
||||
* Find the connection entry for a specific edge (by target kind and field).
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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);
|
||||
if (!entry) return false;
|
||||
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && _isEditable(c));
|
||||
if (!entry || !entry.endpoint) return false;
|
||||
|
||||
const url = entry.endpoint!.replace('{id}', targetId);
|
||||
const body = { [field]: newSourceId };
|
||||
const url = entry.endpoint.replace('{id}', targetId);
|
||||
// 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 {
|
||||
await apiPut(url, body);
|
||||
|
||||
@@ -52,6 +52,43 @@ export function renderEdges(group: SVGGElement, edges: GraphEdge[]): void {
|
||||
const path = _renderEdge(edge);
|
||||
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 {
|
||||
@@ -263,6 +300,14 @@ export function updateEdgesForNode(group: SVGGElement, nodeId: string, nodeMap:
|
||||
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;
|
||||
subtype: string;
|
||||
tags: string[];
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
running?: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
@@ -33,6 +35,17 @@ interface LayoutResult {
|
||||
nodes: Map<string, LayoutNode>;
|
||||
edges: (LayoutEdge & { points: { x: number; y: number }[] | null; fromNode: LayoutNode; toNode: LayoutNode })[];
|
||||
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 {
|
||||
@@ -81,7 +94,7 @@ const ELK_OPTIONS = {
|
||||
*/
|
||||
export async function computeLayout(entities: EntitiesInput): Promise<LayoutResult> {
|
||||
const elk = new ELK();
|
||||
const { nodes: nodeList, edges: edgeList } = buildGraph(entities);
|
||||
const { nodes: nodeList, edges: edgeList, brokenRefs } = buildGraph(entities);
|
||||
|
||||
const elkGraph = {
|
||||
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: 0, y: 0, width: 400, height: 300 };
|
||||
|
||||
return { nodes: nodeMap, edges, bounds };
|
||||
return { nodes: nodeMap, edges, bounds, brokenRefs };
|
||||
}
|
||||
|
||||
/* ── Entity color mapping ── */
|
||||
@@ -207,22 +220,36 @@ function edgeType(fromKind: string, toKind: string, field: string): string {
|
||||
|
||||
/* ── Graph builder ── */
|
||||
|
||||
function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
|
||||
function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[]; brokenRefs: BrokenRef[] } {
|
||||
const nodes: LayoutNode[] = [];
|
||||
const edges: LayoutEdge[] = [];
|
||||
const brokenRefs: BrokenRef[] = [];
|
||||
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 {
|
||||
if (!id || nodeIds.has(id)) return;
|
||||
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 {
|
||||
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(
|
||||
nodes.find(n => n.id === from)?.kind ?? '',
|
||||
nodes.find(n => n.id === to)?.kind ?? '',
|
||||
nodeByIdLocal.get(from)?.kind ?? '',
|
||||
nodeByIdLocal.get(to)?.kind ?? '',
|
||||
field
|
||||
);
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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)
|
||||
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 ──
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
return { nodes, edges, brokenRefs };
|
||||
}
|
||||
|
||||
/* ── 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 { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.ts';
|
||||
import { getCardColor, setCardColor } from './card-colors.ts';
|
||||
import { renderDeviceIconSvg } from './device-icons.ts';
|
||||
import * as P from './icon-paths.ts';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
@@ -22,6 +23,8 @@ interface GraphNode {
|
||||
kind: string;
|
||||
name: string;
|
||||
subtype?: string;
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -360,15 +363,29 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Entity icon (right side)
|
||||
const iconPaths = (SUBTYPE_ICONS[kind]?.[subtype]) || KIND_ICONS[kind];
|
||||
if (iconPaths) {
|
||||
// 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-icon',
|
||||
transform: `translate(${width - 28}, ${height / 2 - 8}) scale(0.667)`,
|
||||
class: 'graph-node-custom-icon',
|
||||
transform: `translate(${width - 28}, ${height / 2 - 8})`,
|
||||
});
|
||||
iconG.innerHTML = iconPaths;
|
||||
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];
|
||||
if (iconPaths) {
|
||||
const iconG = svgEl('g', {
|
||||
class: 'graph-node-icon',
|
||||
transform: `translate(${width - 28}, ${height / 2 - 8}) scale(0.667)`,
|
||||
});
|
||||
iconG.innerHTML = iconPaths;
|
||||
g.appendChild(iconG);
|
||||
}
|
||||
}
|
||||
|
||||
// Running dot
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import {
|
||||
_discoveryScanRunning, set_discoveryScanRunning,
|
||||
_discoveryCache, set_discoveryCache,
|
||||
csptCache,
|
||||
csptCache, mqttSourcesCache,
|
||||
} 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 { 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 { EntitySelect, EntityPalette } from '../core/entity-palette.ts';
|
||||
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
|
||||
import type { MQTTSource } from '../types.ts';
|
||||
|
||||
class AddDeviceModal extends Modal {
|
||||
constructor() { super('add-device-modal'); }
|
||||
@@ -44,6 +45,7 @@ class AddDeviceModal extends Modal {
|
||||
opcChannel: (document.getElementById('device-opc-channel') as HTMLInputElement)?.value || '0',
|
||||
bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.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',
|
||||
wizMinInterval: (document.getElementById('device-wiz-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 _csptEntitySelect: any = null;
|
||||
let _mqttSourceEntitySelect: any = null;
|
||||
|
||||
function _ensureDeviceTypeIconSelect() {
|
||||
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 ─────────────────────────── */
|
||||
|
||||
function _buildDmxProtocolItems() {
|
||||
@@ -297,6 +330,7 @@ export function onDeviceTypeChanged() {
|
||||
_showGameSenseFields(false);
|
||||
_showGroupFields(false);
|
||||
_showOpcFields(false);
|
||||
_showMqttSourceField(false);
|
||||
|
||||
if (isMqttDevice(deviceType)) {
|
||||
// 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 (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
|
||||
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
|
||||
_showMqttSourceField(true);
|
||||
} else if (isMockDevice(deviceType)) {
|
||||
urlGroup.style.display = 'none';
|
||||
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;
|
||||
_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
|
||||
if (isDmxDevice(presetType)) {
|
||||
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();
|
||||
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)) {
|
||||
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';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
csptCache,
|
||||
csptCache, mqttSourcesCache,
|
||||
} 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 { 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 { showToast, showConfirm, desktopFocus } from '../core/ui.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 type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts, ModMenuItemOpts } from '../core/mod-card.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { getBaseOrigin } from './settings.ts';
|
||||
import type { Device } from '../types.ts';
|
||||
import type { Device, MQTTSource } from '../types.ts';
|
||||
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
||||
import { ICON_EDIT } from '../core/icons.ts';
|
||||
|
||||
let _deviceTagsInput: any = null;
|
||||
let _settingsCsptEntitySelect: any = null;
|
||||
let _settingsMqttEntitySelect: any = null;
|
||||
|
||||
/* The General Settings modal groups its many conditional fields into
|
||||
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 {
|
||||
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',
|
||||
nanoleafMinInterval: (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement | null)?.value || '100',
|
||||
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 urlHint = urlGroup.querySelector('.input-hint') as HTMLElement | null;
|
||||
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) {
|
||||
urlGroup.style.display = 'none';
|
||||
urlInput.removeAttribute('required');
|
||||
@@ -458,6 +485,7 @@ export async function showSettings(deviceId: any) {
|
||||
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
|
||||
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
|
||||
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
|
||||
if (mqttSourceGroup) mqttSourceGroup.style.display = '';
|
||||
} else if (isOpenrgbDevice(device.device_type)) {
|
||||
if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
|
||||
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;
|
||||
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();
|
||||
settingsModal.snapshot();
|
||||
settingsModal.open();
|
||||
@@ -819,7 +854,7 @@ export async function showSettings(deviceId: any) {
|
||||
}
|
||||
|
||||
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 async function saveDeviceSettings() {
|
||||
@@ -908,6 +943,9 @@ export async function saveDeviceSettings() {
|
||||
const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || '';
|
||||
body.ble_govee_key = goveeKey;
|
||||
}
|
||||
if (isMqttDevice(settingsModal.deviceType)) {
|
||||
body.mqtt_source_id = (document.getElementById('settings-mqtt-source') as HTMLSelectElement | null)?.value || '';
|
||||
}
|
||||
if (isGroup) {
|
||||
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 !== '');
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
import { GraphCanvas } from '../core/graph-canvas.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 {
|
||||
devicesCache, captureTemplatesCache, ppTemplatesCache,
|
||||
@@ -14,13 +15,14 @@ import {
|
||||
automationsCacheObj, csptCache,
|
||||
} from '../core/state.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 { createFpsSparkline } from '../core/chart-utils.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 * 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';
|
||||
|
||||
// Local type guard for AnchoredRect — `JSON.parse` returns unknown and the
|
||||
@@ -122,8 +124,33 @@ let _dragState: DragState | null = null;
|
||||
let _justDragged = false;
|
||||
let _dragListenersAdded = false;
|
||||
|
||||
// Manual position overrides (persisted in memory; cleared on relayout)
|
||||
let _manualPositions: Map<string, { x: number; y: number }> = new Map();
|
||||
// Manual position overrides — persisted to localStorage so a hand-arranged
|
||||
// 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
|
||||
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 {
|
||||
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
|
||||
_applyManualPositions(nodes, edges);
|
||||
@@ -341,6 +373,7 @@ export async function loadGraphEditor(): Promise<void> {
|
||||
computePorts(nodes as any, edges);
|
||||
_nodeMap = nodes as any;
|
||||
_edges = edges;
|
||||
_brokenRefs = brokenRefs;
|
||||
_bounds = _calcBounds(nodes);
|
||||
_renderGraph(container);
|
||||
} finally {
|
||||
@@ -661,9 +694,147 @@ export async function graphRelayout(): Promise<void> {
|
||||
if (!ok) return;
|
||||
}
|
||||
_manualPositions.clear();
|
||||
_saveManualPositions();
|
||||
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
|
||||
const _ico = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
const _w = window as any;
|
||||
@@ -693,6 +864,25 @@ const ALL_CACHES = [
|
||||
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 {
|
||||
const items = ADD_ENTITY_MAP.map(item => ({
|
||||
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
|
||||
let _entityWatchCleanup: (() => void) | null = null;
|
||||
|
||||
function _watchForNewEntity(): void {
|
||||
function _watchForNewEntity(onNew?: (newId: string) => void, expectKind?: string): void {
|
||||
// Cleanup any previous watcher
|
||||
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
|
||||
const knownIds = new Set<string>();
|
||||
for (const cache of ALL_CACHES) {
|
||||
for (const cache of caches) {
|
||||
for (const item of (cache.data || [])) {
|
||||
if (item.id) knownIds.add(item.id);
|
||||
}
|
||||
@@ -731,9 +967,12 @@ function _watchForNewEntity(): void {
|
||||
if (!Array.isArray(data)) return;
|
||||
for (const item of data) {
|
||||
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;
|
||||
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(() => {
|
||||
const node = _nodeMap?.get(newId);
|
||||
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)
|
||||
const timeout = setTimeout(cleanup, 120_000);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timeout);
|
||||
for (const cache of ALL_CACHES) cache.unsubscribe(handler);
|
||||
for (const cache of caches) cache.unsubscribe(handler);
|
||||
_entityWatchCleanup = null;
|
||||
}
|
||||
|
||||
@@ -829,6 +1068,9 @@ function _renderGraph(container: HTMLElement): void {
|
||||
});
|
||||
markOrphans(nodeGroup, _nodeMap!, _edges!);
|
||||
|
||||
// Health overlay: flag dangling references and dependency cycles.
|
||||
_computeAndMarkIssues(nodeGroup);
|
||||
|
||||
// Animated flow dots for running nodes
|
||||
const runningIds = new Set<string>();
|
||||
for (const node of _nodeMap!.values()) {
|
||||
@@ -845,6 +1087,8 @@ function _renderGraph(container: HTMLElement): void {
|
||||
_canvas.onZoomChange = (z) => {
|
||||
const label = container.querySelector('.graph-zoom-label');
|
||||
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) => {
|
||||
@@ -993,6 +1237,9 @@ function _renderGraph(container: HTMLElement): void {
|
||||
container.focus();
|
||||
// Re-focus when clicking inside the graph
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1048,6 +1295,10 @@ function _graphHTML(): string {
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<span>${t('graph.fullscreen')}</span>
|
||||
</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>
|
||||
<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>
|
||||
@@ -1525,7 +1780,8 @@ function _onEditNode(node: any) {
|
||||
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 = {
|
||||
device: () => _w.removeDevice?.(node.id),
|
||||
capture_template: () => _w.deleteTemplate?.(node.id),
|
||||
@@ -1545,6 +1801,30 @@ function _onDeleteNode(node: any) {
|
||||
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> {
|
||||
const count = _selectedIds.size;
|
||||
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))
|
||||
);
|
||||
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) {
|
||||
const node = _nodeMap?.get(id);
|
||||
if (node) _onDeleteNode(node);
|
||||
if (node) _deleteNodeRaw(node);
|
||||
}
|
||||
_selectedIds.clear();
|
||||
}
|
||||
@@ -1991,17 +2273,37 @@ function _onDragPointerUp(): void {
|
||||
_justDragged = true;
|
||||
requestAnimationFrame(() => { _justDragged = false; });
|
||||
|
||||
const moved: Array<{ id: string; oldX: number; oldY: number; newX: number; newY: number }> = [];
|
||||
|
||||
if (_dragState.multi) {
|
||||
_dragState.nodes.forEach(n => {
|
||||
if (n.el) n.el.classList.remove('dragging');
|
||||
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 {
|
||||
const ds = _dragState as DragStateSingle;
|
||||
ds.el.classList.remove('dragging');
|
||||
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);
|
||||
@@ -2201,11 +2503,26 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
|
||||
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
|
||||
|
||||
if (!_connectListenersAdded) {
|
||||
window.addEventListener('pointermove', _onConnectPointerMove);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2228,12 +2545,20 @@ function _onConnectPointerMove(e: PointerEvent): void {
|
||||
|
||||
svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('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 {
|
||||
if (!_connectState) return;
|
||||
|
||||
const { sourceNodeId, sourceKind, portType, dragPath } = _connectState;
|
||||
const { sourceNodeId, sourceKind, startX, startY, dragPath } = _connectState;
|
||||
|
||||
// Clean up drag edge
|
||||
dragPath.remove();
|
||||
@@ -2241,48 +2566,132 @@ function _onConnectPointerUp(e: PointerEvent): void {
|
||||
if (svgEl) svgEl.classList.remove('connecting');
|
||||
if (_canvas) _canvas.blockPan = false;
|
||||
|
||||
// Clean up port highlights
|
||||
// Clean up port + node highlights
|
||||
const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null;
|
||||
if (nodeGroup) {
|
||||
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');
|
||||
});
|
||||
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 targetPort = elem?.closest?.('.graph-port-in');
|
||||
if (targetPort) {
|
||||
// Dropped on a specific input port — resolve by that port's edge type.
|
||||
const targetNodeId = targetPort.getAttribute('data-node-id') ?? '';
|
||||
const targetKind = targetPort.getAttribute('data-node-kind') ?? '';
|
||||
const targetPortType = targetPort.getAttribute('data-port-type') ?? '';
|
||||
|
||||
if (targetNodeId !== sourceNodeId) {
|
||||
// Find the matching connection
|
||||
const matches = findConnection(targetKind, sourceKind, targetPortType);
|
||||
const matches = _availableMatches(findConnection(targetKind, sourceKind, targetPortType), targetNodeId);
|
||||
if (matches.length === 1) {
|
||||
_doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId);
|
||||
} else if (matches.length > 1) {
|
||||
// Multiple possible fields (e.g., template → picture_source could be capture or pp template)
|
||||
// Resolve by source kind
|
||||
const exact = matches.find(m => m.sourceKind === sourceKind);
|
||||
if (exact) {
|
||||
_doConnect(targetNodeId, targetKind, exact.field, sourceNodeId);
|
||||
// Genuinely ambiguous: the same source kind feeds two distinct
|
||||
// fields (e.g. an automation's activation vs. deactivation scene).
|
||||
_promptConnectionField(matches, targetNodeId, targetKind, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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);
|
||||
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();
|
||||
} 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(); }
|
||||
|
||||
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()!;
|
||||
try {
|
||||
await action.undo();
|
||||
_redoStack.push(action);
|
||||
showToast(t('graph.undone') || `Undone: ${action.label}`, 'info');
|
||||
showToast(t('graph.undone'), 'info');
|
||||
_updateUndoRedoButtons();
|
||||
await loadGraphEditor();
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
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()!;
|
||||
try {
|
||||
await action.redo();
|
||||
_undoStack.push(action);
|
||||
showToast(t('graph.redone') || `Redone: ${action.label}`, 'info');
|
||||
showToast(t('graph.redone'), 'info');
|
||||
_updateUndoRedoButtons();
|
||||
await loadGraphEditor();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
_redoStack.push(action);
|
||||
showToast(e instanceof Error ? e.message : String(e), 'error');
|
||||
_updateUndoRedoButtons();
|
||||
}
|
||||
}
|
||||
@@ -2402,6 +2815,28 @@ export function toggleGraphHelp(): void {
|
||||
|
||||
/* ── 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 {
|
||||
_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
|
||||
|
||||
const toId = edgePath.getAttribute('data-to') ?? '';
|
||||
const fromId = edgePath.getAttribute('data-from') ?? '';
|
||||
const toNode = _nodeMap?.get(toId);
|
||||
if (!toNode) return;
|
||||
|
||||
@@ -2419,19 +2855,13 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'graph-edge-menu-item danger';
|
||||
btn.textContent = t('graph.disconnect') || 'Disconnect';
|
||||
btn.textContent = t('graph.disconnect');
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
_dismissEdgeContextMenu();
|
||||
const ok = await detachConnection(toId, toNode.kind, field);
|
||||
if (ok) {
|
||||
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
|
||||
await loadGraphEditor();
|
||||
} else {
|
||||
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
|
||||
}
|
||||
await _doDetach(toId, toNode.kind, field, fromId);
|
||||
} 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);
|
||||
@@ -2449,16 +2879,9 @@ function _dismissEdgeContextMenu(): void {
|
||||
|
||||
async function _detachSelectedEdge(): Promise<void> {
|
||||
if (!_selectedEdge) return;
|
||||
const { to, field, targetKind } = _selectedEdge;
|
||||
const { from, to, field, targetKind } = _selectedEdge;
|
||||
_selectedEdge = null;
|
||||
|
||||
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');
|
||||
}
|
||||
await _doDetach(to, targetKind, field, from);
|
||||
}
|
||||
|
||||
/* ── Node hover FPS tooltip ── */
|
||||
|
||||
+2
@@ -377,6 +377,8 @@ startTargetOverlay: (...args: any[]) => any;
|
||||
graphZoomIn: (...args: any[]) => any;
|
||||
graphZoomOut: (...args: any[]) => any;
|
||||
graphRelayout: (...args: any[]) => any;
|
||||
graphShowIssues: (...args: any[]) => any;
|
||||
graphExportTopology: (...args: any[]) => any;
|
||||
graphToggleFullscreen: (...args: any[]) => any;
|
||||
graphAddEntity: (...args: any[]) => any;
|
||||
|
||||
|
||||
@@ -362,6 +362,9 @@
|
||||
"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.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.hint": "WebSocket URL for clients to connect and receive LED data",
|
||||
"device.openrgb.url": "OpenRGB URL:",
|
||||
@@ -2582,6 +2585,23 @@
|
||||
"graph.tooltip.fps": "FPS",
|
||||
"graph.tooltip.errors": "Errors",
|
||||
"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.disabled": "Automation disabled",
|
||||
"scene_preset.activated": "Preset activated",
|
||||
|
||||
@@ -417,6 +417,9 @@
|
||||
"device.mqtt_topic": "MQTT Топик:",
|
||||
"device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)",
|
||||
"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.hint": "WebSocket URL для подключения клиентов и получения LED данных",
|
||||
"device.openrgb.url": "OpenRGB URL:",
|
||||
@@ -2264,6 +2267,23 @@
|
||||
"graph.tooltip.fps": "FPS",
|
||||
"graph.tooltip.errors": "Ошибки",
|
||||
"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.disabled": "Автоматизация выключена",
|
||||
"scene_preset.activated": "Пресет активирован",
|
||||
|
||||
@@ -415,6 +415,9 @@
|
||||
"device.mqtt_topic": "MQTT 主题:",
|
||||
"device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name)",
|
||||
"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.hint": "客户端连接并接收 LED 数据的 WebSocket URL",
|
||||
"device.openrgb.url": "OpenRGB URL:",
|
||||
@@ -2260,6 +2263,23 @@
|
||||
"graph.tooltip.fps": "帧率",
|
||||
"graph.tooltip.errors": "错误",
|
||||
"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.disabled": "自动化已禁用",
|
||||
"scene_preset.activated": "预设已激活",
|
||||
|
||||
+223
-10
@@ -4,20 +4,233 @@
|
||||
* Strategy:
|
||||
* - Static assets (/static/): stale-while-revalidate
|
||||
* - 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).
|
||||
// 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 = [
|
||||
'/static/dist/app.bundle.css',
|
||||
'/static/dist/app.bundle.js',
|
||||
'/static/icons/icon-192.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 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’t reach the LED Grab server. Make sure it’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…</p>
|
||||
<p class="foot">LED Grab · 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
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
@@ -66,17 +279,17 @@ self.addEventListener('fetch', (event) => {
|
||||
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') {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() =>
|
||||
new Response(
|
||||
'<html><body style="font-family:system-ui;text-align:center;padding:60px 20px;background:#1a1a1a;color:#ccc">' +
|
||||
'<h2>LED Grab</h2><p>Cannot reach the server. Check that it is running and you are on the same network.</p>' +
|
||||
'<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>' +
|
||||
'</body></html>',
|
||||
{ status: 503, headers: { 'Content-Type': 'text/html' } }
|
||||
)
|
||||
new Response(OFFLINE_HTML, {
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
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>
|
||||
<input type="text" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||
</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="label-row">
|
||||
<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>
|
||||
</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="label-row">
|
||||
<label for="settings-ble-family" data-i18n="device.ble.family">Protocol Family:</label>
|
||||
|
||||
@@ -47,6 +47,13 @@ def output_target_store(_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
|
||||
def processor_manager():
|
||||
"""A mock ProcessorManager — avoids real hardware."""
|
||||
@@ -60,7 +67,7 @@ def processor_manager():
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
# 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_output_target_store] = lambda: output_target_store
|
||||
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)
|
||||
|
||||
@@ -428,6 +436,100 @@ class TestWLEDSchemeInference:
|
||||
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:
|
||||
"""End-to-end coverage: pair, then persist; assert the token is
|
||||
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
|
||||
@@ -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,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"
|
||||
@@ -12,7 +12,12 @@ import signal
|
||||
import threading
|
||||
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:
|
||||
@@ -21,6 +26,33 @@ def test_request_shutdown_sets_should_exit() -> None:
|
||||
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:
|
||||
"""Tray path runs uvicorn on a background thread, so our handlers must
|
||||
actually survive — verify each catchable signal is replaced.
|
||||
|
||||
Reference in New Issue
Block a user