Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aeda935f1 | |||
| a5effba553 | |||
| b83a72e63f | |||
| 0d840adfca | |||
| 1f959932c1 | |||
| 10eb24b2ce | |||
| 66b85b0175 | |||
| bc42604045 | |||
| 3645216669 | |||
| 85da2e538d | |||
| e4d24a02da | |||
| bb3a316e35 | |||
| 49c35a2ea0 | |||
| ef1f9eade2 |
@@ -98,6 +98,9 @@ jobs:
|
||||
print(json.dumps('\n\n'.join(sections)))
|
||||
")
|
||||
|
||||
# Created as draft so the release isn't user-visible until every
|
||||
# build job has attached its assets. The publish-release job at
|
||||
# the end of the workflow flips draft=false once all builds pass.
|
||||
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -105,7 +108,7 @@ jobs:
|
||||
\"tag_name\": \"$TAG\",
|
||||
\"name\": \"LedGrab $TAG\",
|
||||
\"body\": $BODY_JSON,
|
||||
\"draft\": false,
|
||||
\"draft\": true,
|
||||
\"prerelease\": $IS_PRE
|
||||
}")
|
||||
|
||||
@@ -350,3 +353,25 @@ jobs:
|
||||
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
||||
docker push "$REGISTRY:latest"
|
||||
fi
|
||||
|
||||
# ── Publish the release (flip draft=false) ─────────────────
|
||||
# Runs only after every build job succeeded so users never see a
|
||||
# release that's missing artifacts or sha256 sidecars (the in-app
|
||||
# updater refuses to install without them).
|
||||
publish-release:
|
||||
needs: [create-release, build-windows, build-linux, build-docker]
|
||||
if: github.event_name == 'push' && success()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Promote draft release to published
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
curl -s -X PATCH "$BASE_URL/releases/$RELEASE_ID" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"draft": false}'
|
||||
echo "Published release $RELEASE_ID"
|
||||
|
||||
@@ -18,6 +18,7 @@ context.
|
||||
| `05f73ee` | H6 (bindable extraction only) |
|
||||
| `3b8f00e` + `c1aa2eb` | C7 store-side |
|
||||
| `2f15fbb` | H3 |
|
||||
| _uncommitted (2026-05-27 autonomous pass)_ | H6-rest, H8, M7 (foundation + 3 reference files) |
|
||||
|
||||
All commits have ≥1 code-review subagent pass with HIGH findings fixed
|
||||
before commit. Tests pass on each commit; ruff clean; tsc + bundle build
|
||||
@@ -100,16 +101,35 @@ registry.
|
||||
|
||||
**Estimated scope:** 1-2 sessions; coupled to H4.
|
||||
|
||||
#### H8 — `automations.ts` 1410 LOC
|
||||
#### H8 — `automations.ts` 1410 LOC — ✅ DONE (uncommitted, 2026-05-27)
|
||||
|
||||
Frontend mirror of H2 (rule polymorphism). Already addressed on the
|
||||
backend in `98fb61d`; the frontend dispatch on `RuleType` is still
|
||||
backend in `98fb61d`; the frontend dispatch on `RuleType` was
|
||||
hand-rolled.
|
||||
|
||||
**Approach:** introduce a rule-type registry on the frontend matching
|
||||
the backend's `_RULE_HANDLERS` shape.
|
||||
**Done:** the two remaining hand-rolled dispatch ladders were converted
|
||||
to registries keyed by `RuleType`, alongside the pre-existing
|
||||
`RULE_CHIP_RENDERERS`:
|
||||
- `RULE_FIELD_RENDERERS` — the `renderFields` if/elif ladder was
|
||||
extracted into module-level `_renderXxxFields(container, data)`
|
||||
functions (they only ever closed over `container`); the in-row
|
||||
`renderFields` is now a 3-line dispatcher.
|
||||
- `RULE_COLLECTORS` — the `getAutomationEditorRules` if/elif ladder
|
||||
became per-type collectors; the loop is now a registry lookup.
|
||||
- All three registries are typed `Record<RuleType, …>` (compile-time
|
||||
exhaustiveness) and an import-time `_assertRuleHandlerCoverage()`
|
||||
logs loudly if any registry drifts from `RULE_TYPE_KEYS`. (Frontend
|
||||
logs rather than throws — a thrown error at import would brick the
|
||||
whole bundle, not just the editor — the one intentional divergence
|
||||
from the backend's raising `_assert_rule_handler_coverage`.)
|
||||
|
||||
**Estimated scope:** half a session.
|
||||
Adding a new rule type now means: one entry in `RULE_TYPE_KEYS`,
|
||||
`RULE_TYPE_ICONS`, and each of the three registries — and tsc + the
|
||||
coverage check flag any omission.
|
||||
|
||||
Verified: tsc + bundle build clean; typescript-reviewer APPROVE (the
|
||||
extracted renderer bodies are byte-identical to the originals; no stray
|
||||
closure captures; http_poll widget-stash + HA entity loading preserved).
|
||||
|
||||
### MEDIUM
|
||||
|
||||
@@ -161,16 +181,66 @@ extract the frame loop into a separate `PreviewFrameLoop` class.
|
||||
**Estimated scope:** half a session. Low impact since the parallel-change
|
||||
problem is already fixed.
|
||||
|
||||
#### M7 — No shared frontend API client
|
||||
#### M7 — No shared frontend API client — 🟡 FOUNDATION DONE (uncommitted, 2026-05-27)
|
||||
|
||||
**File:** every `static/js/features/*.ts`
|
||||
|
||||
`fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every
|
||||
feature's save / load function. ~25 files.
|
||||
feature's save / load function. ~45 files, ~243 call sites.
|
||||
|
||||
**Approach:** introduce `static/js/core/api-client.ts` with typed
|
||||
methods (`get`, `post`, `put`, `delete`) that handle auth, JSON parsing,
|
||||
error normalisation. Replace `fetchWithAuth` calls across features.
|
||||
**Done:** `static/js/core/api-client.ts` now provides typed
|
||||
`apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` that wrap
|
||||
`fetchWithAuth` (so auth, 401-relogin, retry, timeout, and the offline
|
||||
toast are unchanged) and collapse the repeated
|
||||
`if (!resp.ok) { detail || HTTP <status> } … resp.json()` dance into one
|
||||
call returning a typed body and throwing `ApiError` on failure. The
|
||||
`detail` unwrap is hardened to join FastAPI validation arrays instead of
|
||||
stringifying to `[object Object]`. **35 feature/core files migrated**
|
||||
(covers GET/POST/PUT/DELETE, typed response bodies, custom i18n error
|
||||
messages, silent-failure GETs, bulk `Promise.allSettled` deletes,
|
||||
inline-error saves, array-`detail` joins, fire-and-forget POSTs, and
|
||||
local catch handling) — reviewer-approved for behaviour parity across
|
||||
the riskier divergences. Migrated files include the integration sources
|
||||
(weather / HA / MQTT / HTTP), the template families (capture / audio /
|
||||
audio-processing / pattern), the scene-preset CRUD, the simple-CRUD
|
||||
entity files (sync-clocks / audio-sources / game-integration /
|
||||
gradient / displays / device-discovery), the light-target editors
|
||||
(z2m / ha), the preferences modules (dashboard-layout / card-modes /
|
||||
notifications-watcher), the calibration editors (simple + advanced),
|
||||
the entire `automations.ts` and `devices.ts` CRUD surfaces, and several
|
||||
core utilities (`api-client.ts` itself, `cache.ts`, `command-palette.ts`,
|
||||
`graph-connections.ts`, `tag-input.ts`, `process-picker.ts`,
|
||||
`perf-charts.ts`, `icon-picker.ts`, `update.ts`, `integrations.ts`).
|
||||
|
||||
Also added **14 new locale keys** (en / ru / zh) so the fallback
|
||||
messages the migration surfaces — `pattern.error.save_failed`,
|
||||
`audio_processing.error.save_failed`, `audio_template.error.save_failed`,
|
||||
`audio_template.error.load_failed`, `templates.error.save_failed`,
|
||||
`templates.error.load_failed`, `gradient.error.save_failed`,
|
||||
`target.error.load_failed`, `device.error.load_failed`,
|
||||
`automations.error.{load,save,delete,toggle}_failed`, plus
|
||||
`gradient.error.delete_failed` for ru/zh — are translated instead of
|
||||
hardcoded English. A scan confirms **no `errorMessage: '<English>'`
|
||||
strings remain** in the migrated diff.
|
||||
|
||||
**Remaining:** 9 feature files (~94 call sites). All but one are the
|
||||
big god-modules whose migration is best done as part of their C8/C9/C10
|
||||
splits: `streams.ts` (18), `settings.ts` (18), `targets.ts` (16),
|
||||
`dashboard.ts` (15), `color-strips/index.ts` (8), `graph-editor.ts` (7),
|
||||
`assets.ts` (6 — also blocked by multipart upload + blob download paths
|
||||
that legitimately bypass the JSON client), and `value-sources.ts` (5).
|
||||
The lone leaf file still on `fetchWithAuth` is `pairing-flow.ts` (1) —
|
||||
its branching on raw `Response.status` codes (200 / 409 / 4xx) doesn't
|
||||
fit the api-client contract, so it stays on raw fetch by design.
|
||||
Migration is mechanical but **not** a blind find/replace — each site
|
||||
carries its own localised error key that must be preserved as the
|
||||
`errorMessage` option, and binary/multipart endpoints (e.g.
|
||||
`assets.ts` file upload / blob download) must stay on raw
|
||||
`fetchWithAuth` (the client is JSON-only). Each migrated file ideally
|
||||
gets manual UI smoke-testing. **Behaviour note:** migrated GET sites now
|
||||
prefer the server's `detail` over the generic localised fallback when
|
||||
present — matching what the write paths already did; intended, but
|
||||
user-visible.
|
||||
|
||||
#### M8 — Global `_cached*` `let` vars
|
||||
|
||||
@@ -262,7 +332,11 @@ always start before reading).
|
||||
|
||||
### Other frontend (severity in main list above)
|
||||
|
||||
- **H6 rest** — split remaining ~1100 LOC of `types.ts` into per-entity files
|
||||
- **H6 rest** — ✅ DONE (uncommitted, 2026-05-27): `types.ts` (1140 LOC)
|
||||
split into 18 per-entity files under `types/` (joining the existing
|
||||
`bindable.ts`); `types.ts` is now a ~200-line pure re-export barrel, so
|
||||
every `import { … } from '../types.ts'` still resolves. Reviewer
|
||||
confirmed all 102 exported symbols preserved, none renamed.
|
||||
- **H7** — `device-discovery.ts` 1745 LOC (couple with H4)
|
||||
- **H8** — `automations.ts` 1410 LOC (mirror H2)
|
||||
- **M7** — shared API client
|
||||
@@ -299,6 +373,13 @@ Address H6-rest, C8, C9, C10, H7, H8, M7-M11, L1. See order above.
|
||||
Critical to have typescript-reviewer feedback + manual UI testing after
|
||||
each split.
|
||||
|
||||
> **Progress (2026-05-27, uncommitted):** steps 1 & 2 of the order above
|
||||
> are done — H6-rest (`types.ts` split) and M7-foundation (`api-client.ts`
|
||||
> + 3 reference migrations). H8 (automations registry) also landed. Still
|
||||
> open: C8, C9, C10, H7, the remaining ~40 M7 file migrations, M8-M11, L1.
|
||||
> Next per the order: introduce the API client everywhere (finish M7),
|
||||
> then split `value-sources.ts` (C8).
|
||||
|
||||
### Session B — Device redesign (1-2 sessions)
|
||||
|
||||
Address H4 alone. Touches device storage + provider classes; needs a
|
||||
|
||||
@@ -1,167 +1,54 @@
|
||||
## v0.7.0 (2026-05-26)
|
||||
## v0.8.1 (2026-05-28)
|
||||
|
||||
A device-support release: **seven new device families**, a unified **pairing UX**,
|
||||
a brand-new **HTTP-endpoint** output type, **multi-broker MQTT + Zigbee2MQTT**
|
||||
support, a major **shutdown / data-safety** fix, and a deep architectural
|
||||
refactor pass that landed registry patterns for every dispatch hot path.
|
||||
### User-facing changes
|
||||
|
||||
### Features
|
||||
#### Features
|
||||
|
||||
#### New device types
|
||||
##### Multi-broker MQTT devices
|
||||
|
||||
- **DDP** — standalone Open-Pixel-Control-style target for Pixelblaze / ESPixelStick / xLights / Falcon endpoints, port 4048 ([8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a))
|
||||
- **Yeelight** — Xiaomi/Yeelight bulbs and lightstrips over JSON-RPC on port 55443, SSDP discovery ([4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005))
|
||||
- **WiZ Connected** — Philips WiZ smart bulbs over UDP on port 38899, broadcast discovery ([ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b))
|
||||
- **LIFX** — LIFX bulbs and lightstrips over the binary LIFX LAN protocol on port 56700 ([8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490))
|
||||
- **Govee LAN** — Govee Wi-Fi bulbs and ambient kits, multicast discovery (requires "LAN Control" enabled in the Govee Home app) ([887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d))
|
||||
- **Open Pixel Control (OPC)** — Fadecandy boards, xLights/Falcon, OPC bridges, port 7890 with channel addressing ([31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a))
|
||||
- **Nanoleaf** — Light Panels / Canvas / Shapes / Lines / Elements over the documented HTTP REST API on port 16021 ([426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a))
|
||||
- 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))
|
||||
|
||||
#### New output type
|
||||
##### Schema-driven wiring-graph editor
|
||||
|
||||
- **HTTP endpoint output target** — POST live strip frames to any user-configured HTTP endpoint, alongside WLED / MQTT / Hue. Full editor + storage + routes ([d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800))
|
||||
- The visual graph editor now renders ports and edges generically from a backend-served schema (`GET /api/v1/graph/schema`) instead of hard-coding the connectable-field topology in two places — so client and server can no longer drift
|
||||
- New `GET /api/v1/graph` returns the full nodes + edges + validation topology, and `GET /api/v1/graph/dependents/{kind}/{id}` reports what references an entity ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
||||
|
||||
#### Pairing flow
|
||||
##### Aggregated snapshot endpoint
|
||||
|
||||
- Generic **pairing UX scaffold** — 30-second SVG ring + countdown, instructions, retry/cancel. First concrete consumer is Nanoleaf; Tuya/Twinkly slot into the same shape later ([2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680))
|
||||
- New `GET /api/v1/snapshot` returns all output targets (with processing state + metrics), devices (with brightness), the source / preset / clock lists, and the system block in a **single response** — collapsing the Home Assistant integration's previous ~2N+M request fan-out into one round trip
|
||||
- `?include=` fetches only a subset of sections, and an excluded section also skips its server-side work (e.g. cold-cache hardware brightness probes or the blocking NVML performance query) ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
||||
|
||||
#### MQTT / Zigbee2MQTT
|
||||
#### Bug Fixes
|
||||
|
||||
- **Multi-broker MQTT** + new **Zigbee2MQTT light output target** sharing the HA-Light editor. Legacy single-broker YAML/env config auto-migrates to a "Default Broker" MQTTSource on startup ([530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c))
|
||||
|
||||
#### Editor experience
|
||||
|
||||
- **Live preview** for color-strip sources of every type that can render without external calibration (audio, math_wave, weather, game_event, api_input, mapped, composite, processed) ([337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c))
|
||||
- **Expanded automations** — new rule shapes + matching UI inputs + 285 lines of dispatch coverage ([3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8))
|
||||
- **Expanded value sources** — storage + schema + UI for the new value-source kinds the per-type factory refactor introduced ([737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72))
|
||||
- **Card icon picker expanded** from 44 → 120 icons across 5 new categories (weather, nature, controls, status, office) ([cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94))
|
||||
- **closeIfPristine** modal save-guard — editing an unchanged entity now silently closes the modal instead of firing a misleading "updated" toast ([f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30))
|
||||
- New **MiniSelect** primitive for compact dropdowns that don't justify the full IconSelect grid; **IconSelect** gains a defence-in-depth XSS sanitiser on the icon channel ([9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd), [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138))
|
||||
|
||||
#### Updater
|
||||
|
||||
- **SSRF-validated redirect chain** in the update service so a hostile mirror can't bounce the updater to a private IP. Stricter `restart.ps1` argument handling + clearer logs ([45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Survive PC restart** — SQLite was running WAL with `synchronous=NORMAL` and `Database.close()` was never called, so an unclean Windows shutdown rolled the DB back to the last checkpoint and silently lost recent edits. Now uses `synchronous=FULL` + `wal_autocheckpoint=100` + explicit `wal_checkpoint(TRUNCATE)` on close, and a hidden WM_QUERYENDSESSION / WM_ENDSESSION window keeps Windows from force-killing the process before the lifespan can finish ([e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3))
|
||||
- **Devices PATCH preserves URL** — PATCH-without-`url` (rename / icon-only) used to drop the address into the processor as None ([0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43))
|
||||
- **HA Light brightness scale** — `_send_entity_color` was double-applying `brightness_scale` below 1 (quartered output for a half-scale) and skipping it above 1 (boost lost). Now one `clamp(max(r,g,b) * bs * vs, 0, 255)` pass with regression coverage ([ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60))
|
||||
- **Dashboard "MODIFIED" badge** no longer fires retroactively on un-edited legacy layouts — `userModified` is now driven by actual edits, not deep-equal drift from defaults ([e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d))
|
||||
- **Transport-bar uptime** repaints on `/health` response instead of waiting up to ~10 s for the next poll ([f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e))
|
||||
- **Pre-merge device-support review pass** — `update_device` no longer double-encrypts secrets in memory; `GET /devices` strips paired-only secrets behind boolean flags; SSRF validation on every new driver; corrupt-envelope decrypt returns `""` instead of deleting the device row; `update_device` URL trim matches create; Govee discovery port-4002 collision serialised behind a module lock; Nanoleaf mDNS scan cleans up tasks on cancel; pair endpoint stops logging userinfo / exception bodies ([0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78))
|
||||
- **value_source factory contract** — `_build_game_event` raises `NotImplementedError` (preserves the historical store contract) and `create_source` runs `build_source` before `_check_name_unique` so an invalid `source_type` raises the right error ([c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb))
|
||||
- **`utils/url_scheme` + `utils/net_classify`** were referenced but untracked on a clean checkout — server failed to start with `ModuleNotFoundError`. Now committed ([7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6))
|
||||
|
||||
### Performance
|
||||
|
||||
- **Capture hot paths vectorised** — WGC swaps per-frame ~30 MB BGRA→RGB fancy-index allocations for `cv2.cvtColor` into a 3-slot pre-allocated pool; MSS uses `screenshot.raw + cv2.cvtColor` with 256-byte change-detection; DXcam/BetterCam fixes a silent name-mangling factory leak; dominant-colour reduction is ~10× faster via packed-RGB `np.bincount` ([f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0))
|
||||
- **Event-driven frame hand-off** — `LiveStream` gains a `frame_id` + `Condition`, consumers wait instead of polling, ring buffer grows 3 → 5 slots, `_blend_u16` uses `cv2.addWeighted`. Up to one `frame_time` of glass-to-LED latency saved at matched FPS ([ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81))
|
||||
- **WLED brightness threshold** caches per-frame `np.max` keyed on frame identity instead of reducing the LED array every loop ([6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6))
|
||||
- **Dashboard FPS charts** now diff target ids and only recreate added/removed/detached charts (skipping the history fetch when local samples already exist), and spark SVGs are mutated in place instead of `innerHTML`-rewritten every poll. Memoised patches/devices rendering by content signature so unchanged ticks no longer restart CSS animations ([f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9))
|
||||
- **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
|
||||
|
||||
#### Architecture audit — registry patterns everywhere
|
||||
#### Backend
|
||||
|
||||
- **Color-strip stream dispatch** — `ColorStripStreamManager.acquire()` and `ws_stream._create_stream()` now share a `STREAM_BUILDERS` registry keyed by source type, with import-time coverage assertion against `_SOURCE_TYPE_MAP`. CSS response builder gets the same treatment ([563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac))
|
||||
- **Value-source create / update** — `ValueSourceStore.create_source` shrinks from ~260 → ~25 lines via per-type builder/applier functions in a new `storage.value_source_factories` module ([3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e))
|
||||
- **SystemMetricsValueStream** — three parallel `if/elif` chains collapse into a `MetricSpec(name, read_psutil, read_fallback, normalize, prime)` registry in `core.processing.metric_readers` ([9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346))
|
||||
- **Automation engine** — per-rule-type bodies become `_handle_<kind>` methods, dispatch table built once at class-creation, unknown-type fallback logs instead of silently returning False ([98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d))
|
||||
- **Effect renderer dispatch** — `@_effect_renderer("fire")` decorators + class-level `_RENDERERS` dict replace per-frame dict-rebuild + silent fire fallback ([97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c))
|
||||
- **Output-target response builders** — `isinstance` ladder + silent fabricated-LED fallback replaced with `_TARGET_RESPONSE_BUILDERS` dict and a runtime `RuntimeError` for unknown subclasses ([2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb))
|
||||
- **Versioned data migrations** — replaces a naked `blob.replace(...)` migration with `storage.data_migrations.MigrationRunner` backed by a `data_migrations` audit table and atomic transactions ([563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac))
|
||||
- **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))
|
||||
|
||||
#### Dedup / refactor
|
||||
#### Frontend
|
||||
|
||||
- **Edge-to-LED kernels** in `PixelMapper` + `AdvancedPixelMapper` deduped into a shared `core.capture.edge_interpolation` module ([5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db))
|
||||
- **HA/Z2M `_swap_color_source`** unified behind a shared `light_target_helpers.swap_color_source` helper ([29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf))
|
||||
- **Single-pixel `_average_color`** lifted out of 6 LED drivers into `core.devices.pixel_reduce.average_color` ([cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba))
|
||||
- **Static → single rename** for the color-strip source kind. Storage keeps backward-compatible serialisation ([826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680))
|
||||
- **Bindable types** extracted into `types/bindable.ts`; the wider `types.ts` god-module split is staged for a follow-up frontend sprint ([05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee))
|
||||
- **WebSocket auth** — 11 `except Exception` sites around handshake replaced with a narrow `_WS_SEND_BENIGN_EXC` tuple; receive path adds explicit observability ([ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88))
|
||||
- **Backend hardening bundle** — MQTT task tracking + drain resilience, credential encryption with auto-migration, devices watcher task tracking, WLED scheme inference at boundaries, streaming-upload caps, `asyncio.gather(return_exceptions=True)` on broadcast loops, WebSocket Origin allow-list, `/docs` auth-gate ([898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f))
|
||||
- **Frontend infra** — inbound-event allowlist mirroring the server side, `closeIfPristine` adoption across editors, MiniSelect markup for filter pickers ([ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571))
|
||||
- **PEP-604 union sweep** — `ruff --select UP007,UP045 --fix` converted ~1760 sites from `Optional[T]` / `Union[X, Y]` to `T | None` / `X | Y`. Hooks bumped to ruff v0.15.12 to recognise UP045 ([888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd))
|
||||
- **Typed window globals** — 59 `(window as any).foo` sites across 19 feature modules switched to typed `window.foo` against `global-types.d.ts` ([0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172))
|
||||
- **Processing magic numbers** lifted to named module constants so tests can monkeypatch them ([d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f))
|
||||
- **`Database.ensure_open()`** — module-level singleton reopens cleanly across lifespan cycles, fixing 65 spurious `sqlite3.ProgrammingError` setup failures on Windows pytest aggregate runs ([f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25))
|
||||
- Service-worker refresh for the new bundle ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
||||
|
||||
#### Tests
|
||||
|
||||
- WLED URL scheme integration + IPv6 regression coverage ([907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf))
|
||||
- Lifespan reopen invariants on `Database` ([f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25))
|
||||
- Hundreds of new tests covering every registry / factory / migration introduced above
|
||||
|
||||
#### Tooling / docs
|
||||
|
||||
- `.vex.toml` makes vex the project's primary code-search backend with auto-update + semantic embeddings ([06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba))
|
||||
- `REVIEW_TODO.md` captures audit items deliberately deferred; `TODO.md` records the architecture-audit remainder ([06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba), [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2))
|
||||
- Locale + CLAUDE.md upkeep alongside the new features ([fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51), [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9), [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af), [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4))
|
||||
- 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 (55)</summary>
|
||||
<summary>All Commits (1)</summary>
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| [f591e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f591e25) | fix(storage/database): reopen connection on lifespan restart |
|
||||
| [f6486f9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6486f9) | perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings |
|
||||
| [48dbdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/48dbdb9) | docs(review-todo): check off items addressed in 2026-05-23 autonomous pass |
|
||||
| [0035172](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0035172) | refactor(types): migrate (window as any) statics to typed window globals |
|
||||
| [888f8fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/888f8fd) | refactor(types): PEP-604 union sweep + UP007/UP045 enforcement |
|
||||
| [ea7ee88](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea7ee88) | refactor(api/auth): narrow WS exception catches + observability log |
|
||||
| [d38021f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d38021f) | refactor(processing): hot-path magic numbers -> named module constants |
|
||||
| [507e138](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/507e138) | feat(ui/icon-select): defence-in-depth XSS sanitiser on icon channel |
|
||||
| [907bdaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/907bdaf) | test(url-scheme): WLED route-level integration + IPv6 regression |
|
||||
| [0dd8d43](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0dd8d43) | fix(devices): preserve existing URL on PATCH-without-url |
|
||||
| [fd46c51](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd46c51) | docs: TODO + CLAUDE.md notes + locale keys for new features |
|
||||
| [ddae571](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ddae571) | chore(frontend-infra): inbound-event allowlist + storage/state touch-ups |
|
||||
| [898912f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/898912f) | chore(backend): MQTT/WLED/devices/capture/utils + api routes hardening |
|
||||
| [45d12b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45d12b2) | feat(update-service): SSRF-validated redirects + restart hardening |
|
||||
| [826e680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/826e680) | refactor(color-strip): rename static -> single + frontend follow-through |
|
||||
| [737fd72](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/737fd72) | feat(value-sources): extend storage + schema + UI alongside new kinds |
|
||||
| [3fe66d8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3fe66d8) | feat(automations): expand automation rules + UI + engine coverage |
|
||||
| [f03cb30](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f03cb30) | feat(modal): closeIfPristine save-guard + per-editor adoption |
|
||||
| [9ff83bd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ff83bd) | feat(ui): MiniSelect primitive + IconSelect XSS hardening + typed globals |
|
||||
| [d6cc800](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d6cc800) | feat(http-endpoints): introduce HTTP endpoint output target stack |
|
||||
| [06273ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/06273ba) | chore(tooling): vex semantic-search config + REVIEW_TODO backlog |
|
||||
| [628c6b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/628c6b2) | docs: capture architecture-audit remainder for follow-up sessions |
|
||||
| [2f15fbb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f15fbb) | refactor(output-targets): registry + coverage assertion for response builders |
|
||||
| [c1aa2eb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c1aa2eb) | fix(value-source): preserve store contract for game_event + error precedence |
|
||||
| [3b8f00e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3b8f00e) | refactor(value-source): per-type factories for create / update dispatch |
|
||||
| [05f73ee](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05f73ee) | refactor(types): extract bindable primitives into types/bindable.ts (H6 partial) |
|
||||
| [9f3f346](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f3f346) | refactor(value-source): MetricSpec registry for SystemMetricsValueStream |
|
||||
| [98fb61d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/98fb61d) | refactor(automations): rule dispatch via class-level handler table |
|
||||
| [5fec8db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fec8db) | refactor(capture): lift duplicated edge-to-LED kernels into shared module |
|
||||
| [97dae2c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/97dae2c) | refactor(processing): replace inline effect dispatch with @_effect_renderer registry |
|
||||
| [29bdacf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/29bdacf) | refactor(processing): dedupe HA/Z2M _swap_color_source via shared helper |
|
||||
| [563cbac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/563cbac) | refactor(storage,processing): kind registries + versioned data migrations |
|
||||
| [e24f9d3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e24f9d3) | fix(shutdown): survive PC restart with WAL fsync + Win32 session-end guard |
|
||||
| [e4bf58d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4bf58d) | fix(dashboard): stop showing perpetual MODIFIED for un-edited legacy layouts |
|
||||
| [f1b0f0e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f1b0f0e) | fix(ui): repaint transport-bar uptime as soon as /health responds |
|
||||
| [17684af](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17684af) | docs: record review-fix pass in TODO.md |
|
||||
| [0e3ae78](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e3ae78) | fix(devices): address pre-merge review findings |
|
||||
| [7736bc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7736bc6) | fix(utils): commit url_scheme + net_classify dependencies |
|
||||
| [390d2b4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/390d2b4) | docs: mark expand-device-support branch ready for merge |
|
||||
| [cc87fba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc87fba) | refactor(devices): extract _average_color to pixel_reduce |
|
||||
| [426484a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/426484a) | feat(devices): Nanoleaf OpenAPI target type + first pair-flow user |
|
||||
| [2f31680](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2f31680) | feat(devices): pairing-UX scaffold (Phase 2) |
|
||||
| [31c6c3a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/31c6c3a) | feat(devices): Open Pixel Control (OPC) target type |
|
||||
| [887131d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/887131d) | feat(devices): Govee LAN target type |
|
||||
| [8f9d490](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f9d490) | feat(devices): LIFX LAN target type |
|
||||
| [ede627b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ede627b) | feat(devices): WiZ Connected LAN target type |
|
||||
| [4b65005](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b65005) | feat(devices): Yeelight LAN target type |
|
||||
| [8f1140a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8f1140a) | feat(devices): standalone DDP target type |
|
||||
| [337984c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/337984c) | feat(color-strips): in-editor live preview for all viable source types |
|
||||
| [530316c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/530316c) | feat(mqtt): multi-broker MQTT + Zigbee2MQTT light target |
|
||||
| [6e4c1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e4c1b6) | perf(wled): cache per-frame max-pixel for brightness threshold |
|
||||
| [ee4fa81](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ee4fa81) | perf(processing): event-driven frame hand-off and scheduling fixes |
|
||||
| [f184ef0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f184ef0) | perf(capture): vectorize hot paths and fix engine bugs |
|
||||
| [ad84b60](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ad84b60) | fix(ha-light): apply brightness_scale once and respect boost multipliers |
|
||||
| [cdf7d94](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cdf7d94) | feat(ui): expand card icon picker (44 -> 120 icons, +5 categories) |
|
||||
| Hash | Message | Author |
|
||||
| ---- | ------- | ------ |
|
||||
| [a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba) | feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
# Dashboard Reconciliation — Review Notes
|
||||
|
||||
*Captured 2026-05-26. Session focused on dashboard + perf-card flicker and per-poll re-rendering.*
|
||||
|
||||
*Updated 2026-05-27 — widened the audit beyond the two poll timers and found a **second driver** (server push) plus the **highest-blast-radius site** (`entity-events.ts`). Added §3.5, corrected the "out of scope" reasoning in §5, and confirmed the decision: **commit to the Lit migration**. Implementation deferred — this is still a planning doc, not a spec.*
|
||||
|
||||
This is a thinking-aloud document for whoever picks up reconciliation work next (likely me). It captures the bug class, what's already shipped, what's still latent, the decision ladder we walked through, and the recommendation we landed on. It is **not** a spec — treat any code shown as illustrative.
|
||||
|
||||
---
|
||||
|
||||
## 1. The bug class in one sentence
|
||||
|
||||
> Every place a data-driven render — a poll timer **or** a server-pushed `server:*` event — writes `el.innerHTML = ...`, the existing DOM is torn down — even when the new HTML equals the old — which restarts CSS animations, drops focus, skips transitions, and burns wasted DOM mutation cycles.
|
||||
|
||||
The symptom only becomes visually loud when the destroyed subtree contains a CSS keyframe animation (e.g. the pulsing `.perf-patches-empty-dot`). Everywhere else the cost is silent: lost transitions, broken focus, wasted layout work. The bug is **load-bearing in the architecture**, not in any single call site — that's why we keep coming back to it.
|
||||
|
||||
---
|
||||
|
||||
## 2. What landed in commit `f6486f9` (this session)
|
||||
|
||||
Tactical work — solves the worst cases, does not change the architecture.
|
||||
|
||||
### `server/src/ledgrab/static/js/features/dashboard.ts`
|
||||
- Collapsed the two fast-path branches into one. Fast path runs when `structureUnchanged && !forceFullRender` regardless of `running.length`. Previously, **zero running targets meant every poll rebuilt the entire dashboard** even when nothing changed.
|
||||
- `_lastSyncClockIds` no longer fingerprints `is_running` — pausing/resuming a clock no longer tears down every card. `_updateSyncClocksInPlace` already handles the toggle.
|
||||
- `_updateAutomationsInPlace` now called from the unified fast path. Automation badges were silently going stale on the fast path.
|
||||
- `_initFpsCharts` rewritten diff-based: only destroy charts for ids that left or whose canvas was detached by a DOM swap; only create for new ids; only fetch `/api/metrics/history` when there are genuinely new ids needing seed data.
|
||||
- Sync-clock pause/resume/reset callers + `server:automation_state_changed` SSE handler now use `loadDashboard()` (no force) — `forceFullRender` is now actually load-bearing, meaning "settings changed, full rebuild required."
|
||||
|
||||
### `server/src/ledgrab/static/js/features/perf-charts.ts`
|
||||
- `_renderChartSvg` no longer rewrites `innerHTML` per poll. The SVG skeleton (ref line + sys area/line + app line) is built once via `_ensureSparkNodes` and mutated thereafter. WeakMap cache (`_sparkNodeCache`) keyed by host element avoids the per-tick `querySelector` cost.
|
||||
- Hidden cards (env-disabled GPU/Temp) skip render entirely.
|
||||
- `_fetchPerformance` switched to `fetchWithAuth`.
|
||||
- Hardcoded English strings replaced with `t()` calls. New keys: `perf.no_captures`, `perf.captures_count.{one,few,many,other}`, `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`, `perf.tip.now`, `perf.tip.ago` (en/ru/zh).
|
||||
- Tooltip reads `dashboardPollInterval` per mousemove tick (was captured at bind time).
|
||||
- Dead `<defs><linearGradient>` block removed.
|
||||
- `updateTotalCaptureFpsActual` now delegates to `_paintCaptureFpsActualValue` — single code path.
|
||||
- `updateActivePatches` / `updateDevices` skip the `innerHTML` write when content signature hasn't changed. This is the direct fix for the "READY TO LAUNCH flickers every update" report — the empty-state dot's CSS pulse no longer resets.
|
||||
- Two missing semicolons in `_seedAggregateHistories` (ASI was saving us).
|
||||
|
||||
### Reviewer findings addressed (typescript-reviewer pass)
|
||||
- **HIGH:** `_metricLabel` was looking up `dashboard.perf.${key}` but the FPS family uses `dashboard.perf.total_fps`, `total_capture_fps`, `total_capture_fps_actual`. Tooltip would have shouted `FPS` / `CAPTURE_FPS` / `CAPTURE_FPS_ACTUAL`. Fixed via explicit `METRIC_LABEL_KEYS` map.
|
||||
- **HIGH:** `_ensureSparkNodes` silently coerced `null` children to non-null when the SVG existed but a child was missing. Hardened to validate all four children and rebuild if any are missing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hot spots still latent
|
||||
|
||||
These are the call sites where `innerHTML` is still written every poll. None are flickering today (no CSS animations on their inner elements), but every one is the same bug shape and will bite the next time someone adds a keyframe / transition / focus target inside.
|
||||
|
||||
### `perf-charts.ts`
|
||||
|
||||
| Line | Site | Fires per poll? | Notes |
|
||||
|------|------|-----------------|-------|
|
||||
| 462 | `updateActivePatches` → `listEl.innerHTML` | yes | guarded by signature compare (✓) |
|
||||
| 493 | `updateTotalFps` → `valEl.innerHTML` | yes | FPS value, no inner animation |
|
||||
| 526 | `updateTotalCaptureFps` → `valEl.innerHTML` | yes | same |
|
||||
| 638 | `_paintNetworkValue` → `valEl.innerHTML` | yes | bytes/s value |
|
||||
| 655 | `_paintDeviceLatencyValue` → `valEl.innerHTML` (no-devices hint) | yes | hint span |
|
||||
| 657 | `_paintDeviceLatencyValue` → `valEl.innerHTML` (offline hint) | yes | hint span |
|
||||
| 660 | `_paintDeviceLatencyValue` → `valEl.innerHTML` (ms value) | yes | value |
|
||||
| 676 | `_paintSendTimingValue` → `valEl.innerHTML` (idle hint) | yes | hint span |
|
||||
| 679 | `_paintSendTimingValue` → `valEl.innerHTML` (ms value) | yes | value |
|
||||
| 738 | `_paintErrorsValue` → `valEl.innerHTML` | yes | rate value |
|
||||
| 806 | `updateDevices` → `dotsEl.innerHTML` | yes | guarded by signature compare (✓) |
|
||||
| 1086 | `_renderValuePair` → `mainEl.innerHTML = appVal` | yes | dual sys/app value |
|
||||
| 1088 | `_renderValuePair` → `mainEl.innerHTML = sysVal` | yes | dual sys/app value |
|
||||
| 1094 | `_renderValuePair` → `tagEl.innerHTML` (App tag) | mode='both' only | App tag in `both` mode |
|
||||
| 1181 | `_applyPerfDataToDom` temp hint | only when cpu_temp_hint_key changes | rare |
|
||||
| 1449 | `_paintFpsValue` | seed only | once per init |
|
||||
| 1456 | `_paintCaptureFpsValue` | seed only | once per init |
|
||||
| 1463 | `_paintCaptureFpsActualValue` (no-captures hint) | yes via live updater | now goes through painter |
|
||||
| 1469 | `_paintCaptureFpsActualValue` (value) | yes via live updater | same |
|
||||
| 1499 | `_paintErrorsValue` (duplicate of 738) | seed only | once per init |
|
||||
| 1823 | tooltip `tip.innerHTML` | per mousemove | rate-limited by hover only |
|
||||
|
||||
### `dashboard.ts`
|
||||
|
||||
| Line | Site | Fires per poll? | Notes |
|
||||
|------|------|-----------------|-------|
|
||||
| 275 | `_updateRunningMetrics` → `fpsEl.innerHTML` | per running target | live FPS pill — visible churn |
|
||||
| 293 | `_updateRunningMetrics` → `labelEl.innerHTML` (errors label) | per running target | rebuilt each poll |
|
||||
| 340 | `_updateAutomationsInPlace` → `btn.innerHTML` | only on enable/disable change | low frequency |
|
||||
| 366 | `_updateSyncClocksInPlace` → `btn.innerHTML` | per poll for every clock | wasteful |
|
||||
| 975 | `loadDashboard` first-load → `container.innerHTML` | once per init | fine |
|
||||
| 989 | `loadDashboard` slow path → `dynamic.innerHTML = dynamicHtml` | only when slow path fires | the **big** swap, scoped already |
|
||||
| 1010 | `loadDashboard` error path | rare | fine |
|
||||
| 1416 | `subscribeDashboardLayout` clear | rare | fine |
|
||||
|
||||
### What this list tells us
|
||||
|
||||
- The remaining innerHTML writes are **per-cell value updates** that paint formatted spans (`{value}<span class="perf-fps-unit">fps</span>`). Each rewrite destroys two text nodes + a span every poll across ~10 cells. Not flickering today; will flicker the moment anyone adds an animation to `.perf-fps-unit` or `.perf-fps-ceiling`.
|
||||
- The pattern can be killed without architectural change by splitting these into a stable structure (number text node + static unit span) and only updating `textContent` of the number. That's what L3 / Lit would force naturally.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Beyond dashboard/perf — push-driven reconciliation
|
||||
|
||||
*Added 2026-05-27. The §3 audit was scoped to the two poll timers we were debugging. Widening the `\.innerHTML\s*=` search showed the bug class has a **second driver** and lives outside dashboard/perf too.*
|
||||
|
||||
### Two drivers, not one
|
||||
|
||||
The teardown is triggered by anything that re-renders **without user intent**:
|
||||
|
||||
- **Poll timers** (`setInterval`) — what §2/§3 covered (`dashboard.ts` `_uptimeTimer` + main refresh, `perf-charts.ts` `_pollTimer`).
|
||||
- **Server-pushed `server:*` events** — `core/events-ws.ts` turns each WS message into a `server:*` CustomEvent; feature modules listen and re-render through the *same* `innerHTML` paths.
|
||||
|
||||
So the one-line bug class in §1 reads "poll- **or** push-driven," not just poll.
|
||||
|
||||
### Genuinely-affected sites outside dashboard/perf
|
||||
|
||||
| Site | Driver | Shape | Notes |
|
||||
| ---- | ------ | ----- | ----- |
|
||||
| `core/entity-events.ts` `_invalidateAndReload` | push (`server:entity_changed`, `server:device_health_changed`) | full-**tab** rebuild via `loadTargetsTab` / `loadPictureSources` / `loadAutomations` / `loadIntegrations` | **highest blast radius.** A single pushed entity change tears down and rebuilds an entire tab — losing scroll, focus, open inline editors, restarting card-enter animations. |
|
||||
| `features/game-integration.ts` event feed (`_eventMonitorTimer`) | poll (2 s) | `feed.innerHTML = events.slice(0,20).map(...)` | full 20-item list rebuild every 2 s while the panel is open. |
|
||||
| `features/game-integration.ts` connection test (`_connectionTestTimer`) | poll | `panel.innerHTML = …` per tick | transient, low frequency. |
|
||||
|
||||
`entity-events.ts` already has the **L1 floor applied by hand**: a 600 ms debounce plus a diff check (`oldData === newData`, then length + `id` + `updated_at` compare) that skips the reload when nothing changed. That kills the *no-op* case — but a **real** change still does the full-tab teardown. This is exactly the §4-L1 limitation ("still tears down when content *does* differ"), live across the whole app.
|
||||
|
||||
### Counter-examples that already do it right
|
||||
|
||||
Two poll loops never flicker because they mutate `textContent` on a **stable structure** instead of rewriting `innerHTML`:
|
||||
|
||||
- `core/api.ts` `loadServerInfo` (connection-check poll) — `versionEl.textContent` / `statusEl.textContent`.
|
||||
- `features/color-strips/test.ts` FPS sampler (1 s) — `valueEl.textContent` / `avgEl.textContent`.
|
||||
|
||||
These are live proof that "stable structure + mutate text node" is the fix — i.e. what L3 / Lit force by construction.
|
||||
|
||||
### What this changes about the plan
|
||||
|
||||
The §4 ladder was reasoned entirely around **per-cell** rendering, because that was the visible flicker. The push-driven finding surfaces a second, qualitatively different problem:
|
||||
|
||||
- **Problem A — cell value churn:** every poll, one value span. Loud only with animations. *Mostly fixed in `f6486f9`.* → wants `setText` / skip-if-unchanged.
|
||||
- **Problem B — list/tab teardown:** on change/push, an entire list or tab. Loses scroll/focus/open editors. *Unaddressed.* `entity-events.ts` and the game feed are Problem B. → wants **keyed list reconciliation**.
|
||||
|
||||
Problem B is a **list-level** concern, not a cell-level one. In Lit terms it maps to a keyed `repeat()` directive over the tab/list body — the dashboard-card work in Phase 2 already needs this, but `entity-events.ts` needs it for tabs that §5 used to list as "out of scope." This does **not** change the chosen direction (Lit); it adds `entity-events.ts` as a first-class, high-priority target.
|
||||
|
||||
---
|
||||
|
||||
## 4. Decision ladder
|
||||
|
||||
Walked through with the user 2026-05-26. Captured here so we don't re-litigate.
|
||||
|
||||
### L1 — drop-in `setInnerHtmlIfChanged` helper
|
||||
- **Shape:** `WeakMap<Element, string>` cache; replace every `el.innerHTML = x` with `setInnerHtmlIfChanged(el, x)`.
|
||||
- **Wins:** stops the no-change rewrites globally; zero behavior risk; ~30 call-site changes.
|
||||
- **Misses:** still tears down DOM when content *does* differ (e.g. FPS row values change every tick); doesn't preserve focus/transition state inside a list.
|
||||
- **Verdict:** floor, not ceiling. Worth doing for cells that don't get migrated to L3/Lit.
|
||||
|
||||
### L2 — lint guard
|
||||
- **Shape:** pre-commit script greps `\.innerHTML\s*=` in `static/js/` outside an allowlist, fails the commit.
|
||||
- **Wins:** keeps the discipline; cheap.
|
||||
- **Misses:** only useful as a pair with L1+; bare guard with no helper makes contributors angry.
|
||||
- **Verdict:** pair with whatever helper we land on.
|
||||
|
||||
### L3 — hand-rolled cell-component pattern
|
||||
- **Shape:** `defineCell({ html, refs, mount, update, unmount })` + `reconcileList(host, items, binding)` + `setText/setClass/setAttr` mutators. ~150–300 lines of runtime.
|
||||
- **Wins:** correct by construction; no dependencies; explicit about what mutates; composes with existing customize panel / color picker.
|
||||
- **Misses:** we own the abstraction — it grows over time as we need transitions, async data, focus, devtools, error boundaries. Death by a thousand features.
|
||||
- **Verdict:** second-best. Strong contender if zero-deps is a hard constraint.
|
||||
|
||||
### Lit migration of polling modules — **recommended**
|
||||
- **Shape:** convert each perf cell + each dashboard card cell to a Lit web component. Use `html\`<span>${value}</span>\`` tagged-template + targeted diff. ~5KB gzip added to bundle, no new build step (esbuild handles it).
|
||||
- **Wins:** solves the bug class by design; maintained by Google + community; web-components-based so no framework lock-in; composes with vanilla DOM trivially; mental model is close to current template-string idiom; non-polling code can stay vanilla forever.
|
||||
- **Misses:** introduces a dependency; contributors learn one more thing; rare edge cases (`@html`-equivalent exists and reintroduces the bug if misused).
|
||||
- **Verdict:** best ceiling-to-cost ratio for a small team. Recommended.
|
||||
|
||||
### Full framework rewrite (React / Vue / Solid)
|
||||
- **Verdict:** overkill. The bug class lives in polling paths; the rest of the app is fine. Spending the migration budget on rebuilding IconSelect / EntitySelect / modals / customize panel / graph editor — none of which are broken — is a bad trade.
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommendation
|
||||
|
||||
**Lit for the polling-heavy modules.**
|
||||
|
||||
Migration plan:
|
||||
|
||||
### Phase 0 — spike (2-hour time-box)
|
||||
- Convert `patches` cell to a Lit component, end to end.
|
||||
- Verify it plays nicely with: color picker integration, customize panel layout reorder, `rerenderPerfGrid` reconciliation, `setPerfMode` toggle, hidden-by-env state, the spark tooltip handler.
|
||||
- If any of those break in an unfixable way → pivot to L3.
|
||||
- If they work → commit to the migration.
|
||||
|
||||
### Phase 1 — perf-charts cells
|
||||
1. `patches` (already spiked)
|
||||
2. `devices`
|
||||
3. `fps` / `capture_fps` / `capture_fps_actual` (share a sparkline base class)
|
||||
4. `cpu` / `ram` / `gpu` / `temp` (share `_sparkCardHtml` template family)
|
||||
5. `network` / `device_latency` / `send_timing` / `errors`
|
||||
|
||||
Each is its own PR, dashboard stays working at every step. `renderPerfSection` becomes a registry of Lit components; `rerenderPerfGrid` becomes "reorder existing elements in the grid" (which it mostly already does).
|
||||
|
||||
### Phase 2 — dashboard card cells
|
||||
6. Output target cards (running variant — biggest payoff, has live FPS + uptime + errors)
|
||||
7. Output target cards (stopped variant)
|
||||
8. Sync clock cards
|
||||
9. Automation cards
|
||||
10. Integration (HA / MQTT) cards
|
||||
|
||||
These get bigger wins from the migration because they have nested mutable state (FPS pill, errors cell, health dot, action button) that's currently rebuilt per poll via the `_updateRunningMetrics` path.
|
||||
|
||||
### Highest-impact: `entity-events.ts` tab reconciliation (sequence early)
|
||||
|
||||
`entity-events.ts` (§3.5) is the single highest-blast-radius site and is **not** on the dashboard — it re-renders the Targets / Integrations / Automations tabs on server push. Whether or not those tabs' cells become Lit components, the loader path (`loadTargetsTab` / `loadIntegrations` / `loadAutomations`) should switch from a full `innerHTML` rebuild to a **keyed list reconcile** (a Lit `repeat()` over the tab body). This preserves scroll / focus / open inline editors across pushes. If the goal is "biggest UX win first" rather than "lowest-risk first," sequence this ahead of Phase 2.
|
||||
|
||||
### Phase 3 — stopgap helper for the rest
|
||||
Add `setInnerHtmlIfChanged` and apply to any remaining vanilla polling sites we don't plan to migrate. Add the L2 lint guard at this point — by now everything that polls is either Lit-managed or uses the helper.
|
||||
|
||||
### Out of scope (deliberately) — with one correction (2026-05-27)
|
||||
|
||||
- Targets tab, automations editor, integrations, scene presets — these render on-demand, **but they are ALSO re-rendered on server push** via `entity-events.ts` (see §3.5). The original claim that "the bug class doesn't bite them" was **wrong**: a pushed `server:entity_changed` does a full-tab `innerHTML` teardown. The *editor / on-demand views* can stay vanilla, but the **list/tab render that entity-events triggers needs reconciliation** (a keyed list diff) regardless of whether those cells become Lit components. Treat the entity-events reload path as **in-scope** — it is the highest-blast-radius Problem B site.
|
||||
- Color strips editor, graph editor, settings — genuinely on-demand, no push re-render path, stay vanilla.
|
||||
- Transport bar cells (CPU/Mem chip in the top bar) — read from the same perf payload, can be migrated opportunistically but not urgent.
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions to settle before committing
|
||||
|
||||
These came up during the discussion and weren't resolved:
|
||||
|
||||
1. **Bundle-size budget.** Is +5KB acceptable? Current bundle is 2.7MB so this is noise — but worth confirming there isn't a strict cap (e.g. for slow networks / Android Chaquopy embed).
|
||||
2. **Contributor model.** If the project will grow to multiple contributors, Lit's smaller community vs React's is a recruiting tradeoff. Currently solo-ish, so probably moot.
|
||||
3. **Android TV target.** Chaquopy embed serves the same bundle. Lit works fine in any modern browser — Android TV WebView is Chromium-based. Should be a no-op but verify in Phase 0 spike.
|
||||
4. **Long-term framework intent.** If there's a chance we ever migrate to React/Vue/Solid for the rest of the app, doing Lit now is *not* lock-in (web components are standard), but it does add a second mental model. Probably fine; just naming the tradeoff.
|
||||
5. **Customize panel.** The drag-reorder code in `dashboard-customize.ts` mutates `.dashboard-section` DOM directly. Lit components reorder cleanly via `moveBefore` / `insertBefore` since they're just elements, but the dnd library needs to treat them as opaque drag handles. Phase 0 spike should confirm.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pointers
|
||||
|
||||
- Source files most relevant:
|
||||
- `server/src/ledgrab/static/js/features/dashboard.ts`
|
||||
- `server/src/ledgrab/static/js/features/perf-charts.ts`
|
||||
- `server/src/ledgrab/static/js/features/dashboard-layout.ts` (cell ordering + visibility)
|
||||
- `server/src/ledgrab/static/js/features/dashboard-customize.ts` (drag-reorder UI)
|
||||
- `server/src/ledgrab/static/js/core/card-modes.ts` (mode toggle that hangs off section headers)
|
||||
- `server/src/ledgrab/static/js/core/entity-events.ts` (push-driven tab reloads — §3.5, highest blast radius)
|
||||
- `server/src/ledgrab/static/js/core/events-ws.ts` (WS → `server:*` CustomEvent dispatch)
|
||||
- `server/src/ledgrab/static/js/features/game-integration.ts` (2 s event-feed list rebuild — §3.5)
|
||||
- Most recent reconciliation commit: `f6486f9`.
|
||||
- Related skill files in `~/.claude/skills/`: `frontend-patterns`, `documentation-lookup` (for Lit docs via Context7).
|
||||
- Locale convention: `perf.*` for cross-card primitives, `dashboard.perf.*` for cell titles.
|
||||
|
||||
---
|
||||
|
||||
## 8. If this doc gets stale
|
||||
|
||||
If you read this and the perf cells are already Lit components — delete this file. If you read this and there's a new flicker / focus / transition bug nobody can explain — search for `\.innerHTML\s*=` in `static/js/features/` **and `static/js/core/`** (`entity-events` lives in core) and you've probably found it. For *state loss on a server event* (scroll jump, focus drop, an inline editor closing itself), look at the `server:*` listeners in `core/entity-events.ts` first.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -30,23 +30,39 @@ val ledgrabVersionCode: Int = run {
|
||||
|
||||
android {
|
||||
namespace = "com.ledgrab.android"
|
||||
compileSdk = 34
|
||||
// SDK 35 (Android 15) — required for Play Store from Aug 2025 onward.
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.ledgrab.android"
|
||||
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
|
||||
targetSdk = 34
|
||||
targetSdk = 35
|
||||
// Derived from git commit count (or ANDROID_VERSION_CODE env var
|
||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||
// sideload updates silently refused to install.
|
||||
versionCode = ledgrabVersionCode
|
||||
versionName = "0.7.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 —
|
||||
// otherwise Chaquopy would fail the build searching for it. The
|
||||
// build script (build-scripts/build-pydantic-core.sh) is the
|
||||
// source of truth for which ABIs we *can* ship.
|
||||
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
|
||||
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
|
||||
val ledgrabAbis = buildList {
|
||||
add("arm64-v8a")
|
||||
add("x86_64")
|
||||
add("x86")
|
||||
if (v7Wheel) add("armeabi-v7a")
|
||||
}
|
||||
ndk {
|
||||
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
||||
// emulators), x86 (legacy emulators). Wheels in android/wheels/
|
||||
// must be kept in sync — see build-scripts/build-pydantic-core.sh.
|
||||
abiFilters += listOf("arm64-v8a", "x86_64", "x86")
|
||||
// arm64-v8a is the primary target (real TV hardware).
|
||||
// x86_64/x86 cover emulators.
|
||||
// armeabi-v7a is opt-in: many pre-2018 Mecool/X96/H96 TV boxes
|
||||
// still ship 32-bit ARMv7 — when a wheel exists in wheels/ we
|
||||
// automatically include the ABI in builds.
|
||||
abiFilters += ledgrabAbis
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +70,12 @@ android {
|
||||
// Each split contains only one native ABI's shared libraries + wheels.
|
||||
splits {
|
||||
abi {
|
||||
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
|
||||
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
|
||||
isEnable = true
|
||||
reset()
|
||||
include("arm64-v8a", "x86_64", "x86")
|
||||
if (v7Wheel) include("armeabi-v7a")
|
||||
isUniversalApk = true // also produce a fat APK for sideloading
|
||||
}
|
||||
}
|
||||
@@ -96,10 +115,21 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
signingConfig = if (hasCiSigning) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
// Refuse to silently sign release APKs with the debug
|
||||
// keystore — that's how a debug-signed release accidentally
|
||||
// ships. CI must provide all four signing env vars. If a
|
||||
// local "release" build is genuinely intended for testing,
|
||||
// set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to opt out.
|
||||
val allowDebugSigned =
|
||||
System.getenv("ANDROID_ALLOW_DEBUG_SIGNED_RELEASE") == "1"
|
||||
signingConfig = when {
|
||||
hasCiSigning -> signingConfigs.getByName("release")
|
||||
allowDebugSigned -> signingConfigs.getByName("debug")
|
||||
else -> throw GradleException(
|
||||
"Release builds require signing env vars " +
|
||||
"(ANDROID_KEYSTORE_PATH/PASSWORD, ANDROID_KEY_ALIAS/PASSWORD). " +
|
||||
"Set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to force a debug-signed release."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,6 +205,9 @@ dependencies {
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
// SplashScreen API — keeps a friendly logo on screen while Chaquopy
|
||||
// unpacks the Python stdlib on first launch (can take 1-3s).
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
// QR code generation for displaying server URL on TV
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
||||
|
||||
@@ -26,9 +26,15 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- MediaProjection requires a foreground service -->
|
||||
<!-- Foreground service permissions.
|
||||
FOREGROUND_SERVICE_MEDIA_PROJECTION: required on API 34+ for the
|
||||
MediaProjection capture path.
|
||||
FOREGROUND_SERVICE_SPECIAL_USE: required on API 34+ for the root
|
||||
screenrecord capture path (it doesn't use MediaProjection).
|
||||
Both are declared because the service may run in either mode. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -60,27 +66,41 @@
|
||||
<application
|
||||
android:name=".LedGrabApp"
|
||||
android:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/ic_launcher"
|
||||
android:banner="@drawable/banner_tv"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.LedGrab">
|
||||
|
||||
<!-- TV launcher activity -->
|
||||
<!-- TV launcher activity. Boots through the SplashScreen theme so
|
||||
the (sometimes multi-second) Chaquopy stdlib unpack doesn't
|
||||
show as a black screen on first launch. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.LedGrab.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Foreground service for screen capture + Python server -->
|
||||
<!-- Foreground service for screen capture + Python server.
|
||||
Declares BOTH mediaProjection AND specialUse: only one is
|
||||
active at a time but Android needs to see the union of
|
||||
possible types up-front so it doesn't kill the service when
|
||||
we promote it with a different type at runtime.
|
||||
FOREGROUND_SERVICE_TYPE_SPECIAL_USE on API 34+ requires the
|
||||
PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale below. -->
|
||||
<service
|
||||
android:name=".CaptureService"
|
||||
android:foregroundServiceType="mediaProjection"
|
||||
android:exported="false" />
|
||||
android:foregroundServiceType="mediaProjection|specialUse"
|
||||
android:exported="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Root-mode screen capture for ambient LED sync. Uses /system/bin/screenrecord on rooted devices to avoid MediaProjection's persistent capture indicator overlay, which is required for the always-on ambient-lighting use case." />
|
||||
</service>
|
||||
|
||||
<!-- Autostart — fires on device boot (and package replace).
|
||||
On rooted devices, launches CaptureService directly so capture
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Persists the per-install API key for the embedded FastAPI server.
|
||||
*
|
||||
* The server's auth gate ([ledgrab.api.auth]) requires a Bearer token
|
||||
* for any non-loopback request when ``auth.api_keys`` is configured.
|
||||
* Without a key, LAN clients (phone, laptop) get 401 — which is the
|
||||
* server's secure default but breaks the QR-scan workflow.
|
||||
*
|
||||
* This class generates one key per install (random 32-byte → 64-char
|
||||
* hex), persists it to SharedPreferences, and exposes it to:
|
||||
* - [PythonBridge] which sets ``LEDGRAB_AUTH__API_KEYS=android:<key>``
|
||||
* before uvicorn starts.
|
||||
* - [MainActivity] which embeds the key as a URL fragment
|
||||
* (``http://ip:port/#k=<key>``) in the QR. Fragments are never sent
|
||||
* to the server in HTTP requests, so the key doesn't appear in
|
||||
* access logs.
|
||||
*/
|
||||
class ApiKeyManager(context: Context) {
|
||||
|
||||
private val prefs = context.applicationContext
|
||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// Once we've materialised a key in this process, cache it so
|
||||
// subsequent reads don't hit prefs and don't risk re-checking
|
||||
// length under contention.
|
||||
@Volatile private var cached: String? = null
|
||||
private val lock = Any()
|
||||
|
||||
/** Persistent random API key, generated lazily on first access. */
|
||||
val apiKey: String
|
||||
get() = getOrCreateKey()
|
||||
|
||||
/** Force a new key. Useful if a user thinks the QR was photographed. */
|
||||
fun rotate(): String {
|
||||
synchronized(lock) {
|
||||
val next = generateKey()
|
||||
// apply() is fine for rotation — by definition the user
|
||||
// initiated this and will see the new QR; the worst case
|
||||
// on crash is they need to re-rotate.
|
||||
prefs.edit().putString(KEY_API_KEY, next).apply()
|
||||
cached = next
|
||||
Log.i(TAG, "Rotated API key")
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): String {
|
||||
cached?.let { return it }
|
||||
synchronized(lock) {
|
||||
// Double-checked under the lock.
|
||||
cached?.let { return it }
|
||||
val existing = prefs.getString(KEY_API_KEY, null)
|
||||
if (existing != null && existing.length >= MIN_KEY_LENGTH) {
|
||||
cached = existing
|
||||
return existing
|
||||
}
|
||||
val generated = generateKey()
|
||||
// commit() (synchronous disk write) on the FIRST write so
|
||||
// the key is durable before MainActivity encodes it into a
|
||||
// QR. If the process is killed between QR display and the
|
||||
// async write landing, the user's phone would scan a key
|
||||
// the server never learned about. Subsequent rotates can
|
||||
// safely use apply().
|
||||
prefs.edit().putString(KEY_API_KEY, generated).commit()
|
||||
cached = generated
|
||||
Log.i(TAG, "Generated new API key (length=${generated.length})")
|
||||
return generated
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateKey(): String {
|
||||
val bytes = ByteArray(KEY_BYTES)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
// Hex-encode so the key survives copy/paste, URL fragments, env
|
||||
// vars, and YAML config without escaping concerns. Mask to 0xff
|
||||
// first — Kotlin's Byte is signed, and `%02x` on a negative
|
||||
// Byte sign-extends to an 8-char hex string ("ffffffff" instead
|
||||
// of "ff"), which would produce an invalid key.
|
||||
return bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ApiKeyManager"
|
||||
private const val PREFS_NAME = "ledgrab_auth"
|
||||
private const val KEY_API_KEY = "api_key"
|
||||
private const val KEY_BYTES = 32
|
||||
private const val MIN_KEY_LENGTH = 32
|
||||
|
||||
/** Label used as the LEDGRAB_AUTH__API_KEYS map key. */
|
||||
const val LABEL = "android"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
@@ -15,6 +16,7 @@ import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -26,7 +28,13 @@ import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Foreground service that runs the Python LedGrab server and captures
|
||||
* the screen via MediaProjection.
|
||||
* the screen via MediaProjection or root screenrecord.
|
||||
*
|
||||
* On Android 14+ the foreground-service "type" must match the work
|
||||
* being done. We promote the service with the correct type (mediaProjection
|
||||
* for the consent path, specialUse for the root path) instead of
|
||||
* declaring a single fixed type in the manifest — the manifest now
|
||||
* declares the *union* so promotion at runtime is permitted.
|
||||
*/
|
||||
class CaptureService : Service() {
|
||||
|
||||
@@ -92,15 +100,33 @@ class CaptureService : Service() {
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// CRITICAL: startForeground must be called IMMEDIATELY —
|
||||
// before any other work, especially before getMediaProjection().
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||
|
||||
// CRITICAL: startForeground must be called IMMEDIATELY — before
|
||||
// any other work, especially before getMediaProjection(). The
|
||||
// service type must match the work; pass it explicitly via
|
||||
// ServiceCompat so we stay compatible back to API 24.
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(url))
|
||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
if (useRoot) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
buildNotification(url),
|
||||
type,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Most common cause: missing foregroundServiceType permission
|
||||
// or denied POST_NOTIFICATIONS on API 34+.
|
||||
// or denied POST_NOTIFICATIONS on API 33+.
|
||||
Log.e(TAG, "startForeground failed — service cannot run", e)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
@@ -109,8 +135,6 @@ class CaptureService : Service() {
|
||||
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
||||
isRunning = true
|
||||
|
||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||
|
||||
if (intent == null && !useRoot) {
|
||||
// MediaProjection mode can't recover from a redelivery —
|
||||
// the consent token in the original intent is single-use.
|
||||
@@ -140,10 +164,13 @@ class CaptureService : Service() {
|
||||
return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun apiKey(): String? =
|
||||
(application as? LedGrabApp)?.apiKeyManager?.apiKey
|
||||
|
||||
private fun startRootCapture(url: String) {
|
||||
val newBridge = PythonBridge(this).also { b ->
|
||||
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||
b.startServer(SERVER_PORT)
|
||||
b.startServer(SERVER_PORT, apiKey())
|
||||
}
|
||||
bridge = newBridge
|
||||
|
||||
@@ -167,12 +194,21 @@ class CaptureService : Service() {
|
||||
* Replace the active root pipeline with a fresh instance, reusing
|
||||
* the existing Python bridge (no server restart). Returns true if
|
||||
* the new pipeline launched, false otherwise.
|
||||
*
|
||||
* Synchronized so a concurrent onDestroy() either (a) sees the old
|
||||
* instance and stops it then null-out, or (b) sees the new instance
|
||||
* and stops it. There is no window where a fresh instance can be
|
||||
* orphaned with no one holding a reference to it.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun restartRootPipeline(): Boolean {
|
||||
val currentBridge = bridge ?: return false
|
||||
val old = rootCapture
|
||||
rootCapture = null
|
||||
runCatching { old?.stop() }
|
||||
// Tear down the old instance first so we don't run two
|
||||
// screenrecord processes simultaneously fighting for the GPU.
|
||||
rootCapture?.let { old ->
|
||||
rootCapture = null
|
||||
runCatching { old.stop() }
|
||||
}
|
||||
|
||||
val next = RootScreenrecord(
|
||||
bridge = currentBridge,
|
||||
@@ -180,11 +216,21 @@ class CaptureService : Service() {
|
||||
height = CAPTURE_HEIGHT,
|
||||
fps = CAPTURE_FPS,
|
||||
)
|
||||
// Publish BEFORE start() — if onDestroy fires after this
|
||||
// assignment but before start() completes, the field is non-null
|
||||
// and onDestroy will stop() it properly. start() is idempotent
|
||||
// enough (running=true, then resource construction) that being
|
||||
// raced by stop() at most produces a brief partial-init that
|
||||
// the next stop() call cleans up.
|
||||
rootCapture = next
|
||||
if (!next.start()) {
|
||||
Log.e(TAG, "Root capture failed to restart")
|
||||
// start() already called stop() on itself on the failure
|
||||
// path — but null out the field so the watchdog/onDestroy
|
||||
// don't try to stop it again.
|
||||
rootCapture = null
|
||||
return false
|
||||
}
|
||||
rootCapture = next
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -212,7 +258,7 @@ class CaptureService : Service() {
|
||||
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
|
||||
"restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS",
|
||||
)
|
||||
if (restartAttempts > WATCHDOG_MAX_RESTARTS) {
|
||||
if (restartAttempts >= WATCHDOG_MAX_RESTARTS) {
|
||||
Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts")
|
||||
stopSelf()
|
||||
return@launch
|
||||
@@ -263,7 +309,6 @@ class CaptureService : Service() {
|
||||
val bounds = windowMetrics.bounds
|
||||
widthPixels = bounds.width()
|
||||
heightPixels = bounds.height()
|
||||
// densityDpi is still needed for VirtualDisplay; read from resources.
|
||||
densityDpi = resources.displayMetrics.densityDpi
|
||||
}
|
||||
} else {
|
||||
@@ -276,7 +321,7 @@ class CaptureService : Service() {
|
||||
|
||||
val newBridge = PythonBridge(this).also { b ->
|
||||
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||
b.startServer(SERVER_PORT)
|
||||
b.startServer(SERVER_PORT, apiKey())
|
||||
}
|
||||
bridge = newBridge
|
||||
|
||||
@@ -323,10 +368,10 @@ class CaptureService : Service() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"LedGrab Screen Capture",
|
||||
getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Shows while LedGrab is capturing the screen"
|
||||
description = getString(R.string.notification_channel_description)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
@@ -343,9 +388,14 @@ class CaptureService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("LedGrab Running")
|
||||
.setContentText("Web UI: $url")
|
||||
.setSmallIcon(R.drawable.ic_launcher)
|
||||
.setContentTitle(getString(R.string.notification_title))
|
||||
.setContentText(getString(R.string.notification_text, url))
|
||||
// ic_notification is a monochrome 24dp vector — status-bar
|
||||
// icons must be white-on-transparent or they render as a
|
||||
// gray blob on Android 5+.
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(0xFF64FFDA.toInt())
|
||||
.setColorized(true)
|
||||
.setContentIntent(tapIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
|
||||
@@ -26,9 +26,13 @@ class LedGrabApp : Application() {
|
||||
var initError: Throwable? = null
|
||||
private set
|
||||
|
||||
/** Lazily-initialized API-key manager (see [ApiKeyManager]). */
|
||||
val apiKeyManager: ApiKeyManager by lazy { ApiKeyManager(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
installCrashLogger()
|
||||
pruneOldCrashLogs()
|
||||
try {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(this))
|
||||
@@ -47,6 +51,15 @@ class LedGrabApp : Application() {
|
||||
// Bind application context for the BLE bridge so Python can
|
||||
// scan and connect to BLE LED controllers.
|
||||
BleBridge.init(this)
|
||||
|
||||
// Pre-warm the API key on a background thread. First-launch
|
||||
// generation does a SharedPreferences.commit() (synchronous
|
||||
// disk write — 10-50 ms on slow TV-box flash), which would
|
||||
// hit the Main thread otherwise when MainActivity / CaptureService
|
||||
// reads it. Doing it here makes subsequent reads memory-only.
|
||||
Thread({
|
||||
runCatching { apiKeyManager.apiKey }
|
||||
}, "ledgrab-apikey-warmup").apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +90,24 @@ class LedGrabApp : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the most recent [MAX_CRASH_LOGS] crash files so a
|
||||
* long-lived install doesn't slowly fill its private storage with
|
||||
* historical traces. Cheap on every launch — listFiles is O(n)
|
||||
* but n is tiny by construction.
|
||||
*/
|
||||
private fun pruneOldCrashLogs() {
|
||||
val logs = filesDir.listFiles { f ->
|
||||
f.isFile && f.name.startsWith("crash-") && f.name.endsWith(".log")
|
||||
} ?: return
|
||||
if (logs.size <= MAX_CRASH_LOGS) return
|
||||
logs.sortedByDescending { it.lastModified() }
|
||||
.drop(MAX_CRASH_LOGS)
|
||||
.forEach { runCatching { it.delete() } }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LedGrabApp"
|
||||
private const val MAX_CRASH_LOGS = 10
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.Manifest
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
@@ -13,12 +16,16 @@ import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewStub
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.Button
|
||||
import android.widget.CheckBox
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.app.Activity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -46,25 +53,47 @@ class MainActivity : Activity() {
|
||||
private const val SERVER_PORT = 8080
|
||||
private const val REQUEST_MEDIA_PROJECTION = 1001
|
||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||
private const val QR_SIZE_PX = 560
|
||||
}
|
||||
|
||||
// Stopped-state views (always inflated).
|
||||
private lateinit var stoppedPanel: View
|
||||
private lateinit var runningPanel: View
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var urlText: TextView
|
||||
private lateinit var qrImage: ImageView
|
||||
private lateinit var toggleButton: Button
|
||||
private lateinit var stopButtonRunning: Button
|
||||
private lateinit var versionText: TextView
|
||||
private lateinit var autostartCheck: CheckBox
|
||||
private lateinit var autostartPrefs: AutostartPrefs
|
||||
|
||||
// Running-state views (lazy-inflated via ViewStub).
|
||||
private lateinit var runningPanelStub: ViewStub
|
||||
private var runningPanel: View? = null
|
||||
private var urlText: TextView? = null
|
||||
private var qrImage: ImageView? = null
|
||||
private var stopButtonRunning: Button? = null
|
||||
private var statusDot: View? = null
|
||||
private var statusDotAnimator: ObjectAnimator? = null
|
||||
|
||||
// Cache of the most recently rendered QR (and the URL it encodes).
|
||||
// updateUI() runs on every onResume (HDMI-CEC wakes, app switches,
|
||||
// overlay dismissal, etc.). Rebuilding the 560×560 bitmap each time
|
||||
// is wasteful — usually the IP and key are unchanged. Cache and
|
||||
// short-circuit when the URL matches.
|
||||
private var cachedQrUrl: String? = null
|
||||
private var cachedQrBitmap: Bitmap? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install the splash screen BEFORE super.onCreate so the system
|
||||
// keeps it on screen until our first frame is ready. This hides
|
||||
// the Chaquopy stdlib unpack delay on cold first launch.
|
||||
val splashScreen = installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Surface fatal Python init errors instead of crashing.
|
||||
val initError = (application as? LedGrabApp)?.initError
|
||||
if (initError != null) {
|
||||
// Tell the splash screen to dismiss immediately — we're
|
||||
// about to render an error screen, not the main UI.
|
||||
splashScreen.setKeepOnScreenCondition { false }
|
||||
showFatalErrorScreen(initError)
|
||||
return
|
||||
}
|
||||
@@ -72,39 +101,62 @@ class MainActivity : Activity() {
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
stoppedPanel = findViewById(R.id.stopped_panel)
|
||||
runningPanel = findViewById(R.id.running_panel)
|
||||
runningPanelStub = findViewById(R.id.running_panel_stub)
|
||||
statusText = findViewById(R.id.status_text)
|
||||
urlText = findViewById(R.id.url_text)
|
||||
qrImage = findViewById(R.id.qr_image)
|
||||
toggleButton = findViewById(R.id.toggle_button)
|
||||
stopButtonRunning = findViewById(R.id.stop_button_running)
|
||||
versionText = findViewById(R.id.version_text)
|
||||
autostartCheck = findViewById(R.id.autostart_check)
|
||||
|
||||
val versionName = packageManager
|
||||
.getPackageInfo(packageName, 0).versionName
|
||||
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
|
||||
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
|
||||
|
||||
autostartPrefs = AutostartPrefs(this)
|
||||
autostartCheck.isChecked = autostartPrefs.isEnabled
|
||||
// Autostart only takes effect on rooted devices — grey it out
|
||||
// on unrooted hardware so users don't expect magic. Cheap probe
|
||||
// (file-existence only, no process spawn).
|
||||
if (!Root.looksRooted()) {
|
||||
autostartCheck.isEnabled = false
|
||||
autostartCheck.text = getString(R.string.autostart_unavailable)
|
||||
}
|
||||
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
|
||||
autostartPrefs.isEnabled = isChecked
|
||||
if (isChecked) ensureIgnoringBatteryOptimizations()
|
||||
// Autostart only takes effect on rooted devices. Hide the
|
||||
// checkbox entirely on unrooted hardware instead of showing a
|
||||
// disabled-but-visible control, which reads as broken UI from
|
||||
// across the room.
|
||||
if (Root.looksRooted()) {
|
||||
autostartCheck.visibility = View.VISIBLE
|
||||
autostartCheck.isChecked = autostartPrefs.isEnabled
|
||||
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
|
||||
autostartPrefs.isEnabled = isChecked
|
||||
if (isChecked) ensureIgnoringBatteryOptimizations()
|
||||
}
|
||||
} else {
|
||||
autostartCheck.visibility = View.GONE
|
||||
}
|
||||
|
||||
toggleButton.setOnClickListener { startCapture() }
|
||||
stopButtonRunning.setOnClickListener { stopCaptureService() }
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopStatusDotPulse()
|
||||
uiScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
// ObjectAnimator retains a hard reference to the dot View. On
|
||||
// backgrounded TV apps onDestroy may never fire, so cancel here
|
||||
// to avoid leaking the entire view hierarchy through an
|
||||
// INFINITE-repeat animator.
|
||||
stopStatusDotPulse()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Restart the pulse if we returned to the foreground while the
|
||||
// service is still running. The running panel's view may have
|
||||
// been recreated; ensureRunningPanelInflated already keys off
|
||||
// the field reference.
|
||||
if (CaptureService.isRunning && ::stoppedPanel.isInitialized) {
|
||||
updateUI()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to go through the MediaProjection consent flow or
|
||||
* jump straight into root capture. Root check is fast but may block
|
||||
@@ -112,20 +164,19 @@ class MainActivity : Activity() {
|
||||
* on the UI thread is acceptable because we're responding to a
|
||||
* button press and we want to block until the user answers.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
uiScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startCapture() {
|
||||
// `su -c id` can block for seconds while Magisk shows its grant
|
||||
// dialog; running it on the Main thread caused ANRs.
|
||||
// dialog; running it on the Main thread caused ANRs. Render an
|
||||
// explicit "starting" state so the button doesn't look frozen.
|
||||
val originalText = toggleButton.text
|
||||
toggleButton.isEnabled = false
|
||||
statusText.text = "Checking root access…"
|
||||
toggleButton.text = getString(R.string.btn_starting)
|
||||
statusText.text = getString(R.string.status_checking_root)
|
||||
uiScope.launch(Dispatchers.IO) {
|
||||
val rooted = Root.requestGrant()
|
||||
withContext(Dispatchers.Main) {
|
||||
toggleButton.isEnabled = true
|
||||
toggleButton.text = originalText
|
||||
statusText.text = ""
|
||||
if (rooted) {
|
||||
Log.i(TAG, "Root available — skipping MediaProjection consent")
|
||||
@@ -156,7 +207,7 @@ class MainActivity : Activity() {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
startCaptureService(resultCode, data)
|
||||
} else {
|
||||
statusText.text = "Permission denied — screen capture requires authorization"
|
||||
statusText.text = getString(R.string.status_permission_denied)
|
||||
Log.w(TAG, "MediaProjection permission denied")
|
||||
}
|
||||
}
|
||||
@@ -174,42 +225,130 @@ class MainActivity : Activity() {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun updateUI() {
|
||||
if (CaptureService.isRunning) {
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
private fun ensureRunningPanelInflated(): View {
|
||||
runningPanel?.let { return it }
|
||||
val view = runningPanelStub.inflate()
|
||||
urlText = view.findViewById(R.id.url_text)
|
||||
qrImage = view.findViewById(R.id.qr_image)
|
||||
stopButtonRunning = view.findViewById(R.id.stop_button_running)
|
||||
statusDot = view.findViewById(R.id.status_dot)
|
||||
stopButtonRunning?.setOnClickListener { stopCaptureService() }
|
||||
runningPanel = view
|
||||
return view
|
||||
}
|
||||
|
||||
urlText.text = url
|
||||
qrImage.setImageBitmap(null)
|
||||
// Build the bitmap pixels off the Main thread — encode + 313k
|
||||
// setPixel calls were noticeably janky on slow TV boxes.
|
||||
uiScope.launch(Dispatchers.Default) {
|
||||
val bitmap = generateQrCode(url)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (CaptureService.isRunning && urlText.text == url) {
|
||||
qrImage.setImageBitmap(bitmap)
|
||||
private fun updateUI() {
|
||||
// Fatal-init-error path took over setContentView and the
|
||||
// lateinit view fields are unassigned. Guard so any future
|
||||
// caller (Resume, broadcast receiver, etc.) doesn't NPE.
|
||||
if (!::stoppedPanel.isInitialized) return
|
||||
if (CaptureService.isRunning) {
|
||||
val running = ensureRunningPanelInflated()
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this)
|
||||
if (localIp == null) {
|
||||
// No network — show the no-network state inside the
|
||||
// stopped panel and keep capture stopped. The service
|
||||
// is alive (capture works on loopback) but the URL/QR
|
||||
// are useless without a routable address.
|
||||
statusText.text = getString(R.string.status_no_network)
|
||||
stoppedPanel.visibility = View.VISIBLE
|
||||
versionText.visibility = View.VISIBLE
|
||||
running.visibility = View.GONE
|
||||
toggleButton.requestFocus()
|
||||
return
|
||||
}
|
||||
|
||||
val displayUrl = "http://$localIp:$SERVER_PORT"
|
||||
val qrUrl = qrUrlFor(displayUrl)
|
||||
|
||||
urlText?.text = displayUrl
|
||||
val cachedForUrl = cachedQrBitmap?.takeIf { cachedQrUrl == qrUrl }
|
||||
if (cachedForUrl != null) {
|
||||
qrImage?.setImageBitmap(cachedForUrl)
|
||||
} else {
|
||||
qrImage?.setImageBitmap(null)
|
||||
// Build the bitmap pixels off the Main thread — encode + 313k
|
||||
// setPixel calls were noticeably janky on slow TV boxes.
|
||||
uiScope.launch(Dispatchers.Default) {
|
||||
val bitmap = generateQrCode(qrUrl)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (CaptureService.isRunning && urlText?.text == displayUrl) {
|
||||
cachedQrUrl = qrUrl
|
||||
cachedQrBitmap = bitmap
|
||||
qrImage?.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stoppedPanel.visibility = View.GONE
|
||||
versionText.visibility = View.GONE
|
||||
runningPanel.visibility = View.VISIBLE
|
||||
stopButtonRunning.requestFocus()
|
||||
running.visibility = View.VISIBLE
|
||||
stopButtonRunning?.requestFocus()
|
||||
startStatusDotPulse()
|
||||
} else {
|
||||
urlText.text = ""
|
||||
qrImage.setImageBitmap(null)
|
||||
stopStatusDotPulse()
|
||||
urlText?.text = ""
|
||||
qrImage?.setImageBitmap(null)
|
||||
// Drop the cached bitmap so a Start → IP change → Start
|
||||
// sequence rebuilds the QR for the new address.
|
||||
cachedQrUrl = null
|
||||
cachedQrBitmap = null
|
||||
|
||||
runningPanel.visibility = View.GONE
|
||||
runningPanel?.visibility = View.GONE
|
||||
stoppedPanel.visibility = View.VISIBLE
|
||||
versionText.visibility = View.VISIBLE
|
||||
toggleButton.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL we encode into the QR. Embeds the API key as a
|
||||
* URL fragment (``#k=<token>``) so:
|
||||
* - The token never appears in HTTP requests (fragments aren't
|
||||
* sent over the wire) — no access-log leak.
|
||||
* - The frontend can read [location.hash] on first visit and
|
||||
* persist the key to localStorage (see static/js/app.ts).
|
||||
* - The visible URL chip stays short and human-readable.
|
||||
*
|
||||
* The chip text in [updateUI] intentionally uses the *base* URL
|
||||
* (without the fragment) so a human reading the URL out loud
|
||||
* doesn't have to dictate 64 hex chars; only the QR carries the
|
||||
* key. Do not collapse these into a single string — that would
|
||||
* leak the key onto the screen.
|
||||
*/
|
||||
private fun qrUrlFor(base: String): String {
|
||||
val key = (application as? LedGrabApp)?.apiKeyManager?.apiKey
|
||||
return if (key.isNullOrBlank()) base else "$base/#k=$key"
|
||||
}
|
||||
|
||||
private fun startStatusDotPulse() {
|
||||
val dot = statusDot ?: return
|
||||
if (statusDotAnimator?.isStarted == true) return
|
||||
val animator = ObjectAnimator.ofFloat(dot, "alpha", 1f, 0.35f).apply {
|
||||
duration = 900
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
repeatMode = ValueAnimator.REVERSE
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
animator.start()
|
||||
statusDotAnimator = animator
|
||||
}
|
||||
|
||||
private fun stopStatusDotPulse() {
|
||||
statusDotAnimator?.cancel()
|
||||
statusDotAnimator = null
|
||||
statusDot?.alpha = 1f
|
||||
}
|
||||
|
||||
private fun generateQrCode(text: String): Bitmap {
|
||||
val size = 560
|
||||
val size = QR_SIZE_PX
|
||||
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
|
||||
// ALPHA_8 = 1 byte/px instead of 2 (RGB_565) or 4 (ARGB_8888).
|
||||
// The ImageView gets tinted white via the matrix — for a pure
|
||||
// black-and-white QR that's all we need and it halves heap usage
|
||||
// compared to the previous RGB_565 path.
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val pixels = IntArray(size * size)
|
||||
for (y in 0 until size) {
|
||||
val rowOffset = y * size
|
||||
@@ -218,34 +357,54 @@ class MainActivity : Activity() {
|
||||
if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
|
||||
}
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
|
||||
bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
|
||||
* Rendered programmatically so we don't depend on the regular layout
|
||||
* (which itself may reference resources affected by the failure).
|
||||
* Stack trace is hidden behind a "Show details" toggle so we don't
|
||||
* print user-path data on shared TV screens by default.
|
||||
*/
|
||||
private fun showFatalErrorScreen(error: Throwable) {
|
||||
Log.e(TAG, "Fatal init error — showing error screen", error)
|
||||
val stackText = android.util.Log.getStackTraceString(error)
|
||||
val container = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
val stackText = Log.getStackTraceString(error)
|
||||
val container = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(48, 48, 48, 48)
|
||||
}
|
||||
val title = TextView(this).apply {
|
||||
text = "LedGrab failed to start"
|
||||
text = getString(R.string.fatal_title)
|
||||
textSize = 22f
|
||||
}
|
||||
val description = TextView(this).apply {
|
||||
text = getString(R.string.fatal_body_prefix)
|
||||
textSize = 14f
|
||||
setPadding(0, 24, 0, 12)
|
||||
}
|
||||
val body = TextView(this).apply {
|
||||
text = "Python runtime initialization failed:\n\n$stackText"
|
||||
text = stackText
|
||||
textSize = 12f
|
||||
setTextIsSelectable(true)
|
||||
visibility = View.GONE
|
||||
}
|
||||
val scroll = ScrollView(this).apply {
|
||||
addView(body)
|
||||
visibility = View.GONE
|
||||
}
|
||||
val toggleBtn = Button(this).apply {
|
||||
text = getString(R.string.fatal_show_details)
|
||||
setOnClickListener {
|
||||
val showing = scroll.visibility == View.VISIBLE
|
||||
scroll.visibility = if (showing) View.GONE else View.VISIBLE
|
||||
body.visibility = scroll.visibility
|
||||
text = getString(
|
||||
if (showing) R.string.fatal_show_details else R.string.fatal_hide_details,
|
||||
)
|
||||
}
|
||||
}
|
||||
val copyBtn = Button(this).apply {
|
||||
text = "Copy log"
|
||||
text = getString(R.string.fatal_copy_log)
|
||||
setOnClickListener {
|
||||
val cm = getSystemService(CLIPBOARD_SERVICE)
|
||||
as android.content.ClipboardManager
|
||||
@@ -254,19 +413,20 @@ class MainActivity : Activity() {
|
||||
)
|
||||
}
|
||||
}
|
||||
val scroll = android.widget.ScrollView(this).apply { addView(body) }
|
||||
container.addView(title)
|
||||
container.addView(description)
|
||||
container.addView(toggleBtn)
|
||||
container.addView(copyBtn)
|
||||
container.addView(scroll)
|
||||
setContentView(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to exempt LedGrab from battery optimization. On
|
||||
* TV boxes this is usually a no-op, but on phones Doze/App Standby
|
||||
* will kill the foreground service after a few hours of sleep. We
|
||||
* only ask when autostart is turned on. No-op on pre-M or when
|
||||
* already exempt.
|
||||
* Prompt the user to exempt LedGrab from battery optimization.
|
||||
* Strictly a phone-side concern (Doze/App Standby kill the FG
|
||||
* service after hours of sleep); essentially a no-op on TV boxes.
|
||||
* Only asked when autostart is turned on, which is itself only
|
||||
* available on rooted devices.
|
||||
*
|
||||
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
|
||||
* — LedGrab's ambient-capture use case falls under the documented
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.ledgrab.android
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import java.net.Inet4Address
|
||||
|
||||
/**
|
||||
@@ -11,18 +13,58 @@ import java.net.Inet4Address
|
||||
object NetworkUtils {
|
||||
|
||||
/**
|
||||
* Return the device's local IPv4 address on the active network,
|
||||
* or `null` if unavailable.
|
||||
* Return the device's local IPv4 address, preferring (in order):
|
||||
* - Ethernet (wired TV-box link)
|
||||
* - Wi-Fi
|
||||
* - any other transport
|
||||
* - whatever the active network reports
|
||||
*
|
||||
* Returns ``null`` only when no IPv4 link addresses exist at all.
|
||||
*
|
||||
* Why not just ``activeNetwork``: on TV boxes with both Ethernet
|
||||
* AND Wi-Fi connected, Android's active-network heuristic can
|
||||
* pick Wi-Fi while the user's phone is on the Ethernet subnet —
|
||||
* leading to a URL/QR that the phone can't reach.
|
||||
*/
|
||||
fun getLocalIpAddress(context: Context): String? {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = cm.activeNetwork ?: return null
|
||||
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
|
||||
// TODO(AP-mode): On TV boxes acting as a Wi-Fi tether/hotspot,
|
||||
// TRANSPORT_WIFI here will resolve to the AP-side interface
|
||||
// (typically 192.168.43.x) which clients on the user's actual
|
||||
// home LAN can't reach. Detecting AP mode requires the @SystemApi
|
||||
// WifiManager.getWifiApState reflection trick — defer until a
|
||||
// user reports needing it.
|
||||
val networks = cm.allNetworks
|
||||
if (networks.isEmpty()) return ipv4Of(cm, cm.activeNetwork ?: return null)
|
||||
|
||||
val ranked = networks
|
||||
.mapNotNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@mapNotNull null
|
||||
val rank = when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 0
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 1
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 3
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4
|
||||
else -> 2
|
||||
}
|
||||
Triple(rank, n, caps)
|
||||
}
|
||||
.sortedBy { it.first }
|
||||
|
||||
for ((_, network, _) in ranked) {
|
||||
val ip = ipv4Of(cm, network)
|
||||
if (ip != null) return ip
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun ipv4Of(cm: ConnectivityManager, network: Network): String? {
|
||||
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
|
||||
return props.linkAddresses
|
||||
.asSequence()
|
||||
.map { it.address }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress }
|
||||
.firstOrNull { !it.isLoopbackAddress && !it.isLinkLocalAddress }
|
||||
?.hostAddress
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import com.chaquo.python.Python
|
||||
* Bridge between Kotlin and the LedGrab Python server.
|
||||
*
|
||||
* All Python calls go through Chaquopy's `Python.getInstance()`.
|
||||
* Frame data crosses the JNI boundary as a `ByteArray`.
|
||||
* Frame data crosses the JNI boundary as a `ByteArray` (reused across
|
||||
* frames — see ScreenCapture / RootScreenrecord for buffer pools).
|
||||
*/
|
||||
class PythonBridge(private val context: Context) {
|
||||
|
||||
@@ -55,9 +56,14 @@ class PythonBridge(private val context: Context) {
|
||||
/**
|
||||
* Start the LedGrab FastAPI server on a background thread.
|
||||
*
|
||||
* This blocks until [stopServer] is called, so it runs in its own thread.
|
||||
* Passes [apiKey] through so the Python server's auth gate accepts
|
||||
* Bearer-authenticated LAN requests; null disables auth (loopback
|
||||
* only — see [ApiKeyManager]).
|
||||
*
|
||||
* This blocks until [stopServer] is called, so it runs in its own
|
||||
* thread.
|
||||
*/
|
||||
fun startServer(port: Int = 8080) {
|
||||
fun startServer(port: Int = 8080, apiKey: String? = null) {
|
||||
if (running) {
|
||||
Log.w(TAG, "Server already running")
|
||||
return
|
||||
@@ -71,7 +77,11 @@ class PythonBridge(private val context: Context) {
|
||||
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
|
||||
val py = Python.getInstance()
|
||||
val entry = py.getModule("ledgrab.android_entry")
|
||||
entry.callAttr("start_server", dataDir, port)
|
||||
if (apiKey != null) {
|
||||
entry.callAttr("start_server", dataDir, port, apiKey)
|
||||
} else {
|
||||
entry.callAttr("start_server", dataDir, port)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Python server error", e)
|
||||
} finally {
|
||||
@@ -106,7 +116,8 @@ class PythonBridge(private val context: Context) {
|
||||
*
|
||||
* Called from [ScreenCapture] on the capture thread. The byte array
|
||||
* crosses the JNI boundary — keep frames small (downscale to 480p
|
||||
* before calling).
|
||||
* before calling) and pass reusable buffers (see ScreenCapture's
|
||||
* buffer pool).
|
||||
*/
|
||||
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
|
||||
if (!running) return
|
||||
|
||||
@@ -100,14 +100,41 @@ object Root {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an `su -c <cmd>` command. Returns true on exit-zero. Failure
|
||||
* invalidates the cached grant so the next [requestGrant] re-checks
|
||||
* (covers cases like Magisk grant being revoked mid-session).
|
||||
* Run a command as root.
|
||||
*
|
||||
* The [argv] array is passed to `su -c` as **a single string** built by
|
||||
* shell-quoting each element. This prevents the shell-injection class
|
||||
* of bug where a caller passes user-influenced data containing
|
||||
* spaces, semicolons, or backticks: each element is treated as a
|
||||
* single shell token regardless of contents.
|
||||
*
|
||||
* Returns true on exit-zero. Failure invalidates the cached grant so
|
||||
* the next [requestGrant] re-checks (covers cases like Magisk grant
|
||||
* being revoked mid-session).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun runAsRoot(cmd: String, timeoutSeconds: Long = 5): Boolean {
|
||||
@JvmOverloads
|
||||
fun runAsRoot(argv: Array<String>, timeoutSeconds: Long = 5): Boolean {
|
||||
require(argv.isNotEmpty()) { "runAsRoot called with empty argv" }
|
||||
val quoted = argv.joinToString(" ") { shellQuote(it) }
|
||||
return execSu(quoted, timeoutSeconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for fully-trusted constant commands (e.g.
|
||||
* ``runAsRoot("pkill -TERM screenrecord")``). DO NOT pass anything
|
||||
* derived from user input through this overload — use [runAsRoot]
|
||||
* with an argv array instead so each token is quoted individually.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun runAsRoot(command: String, timeoutSeconds: Long = 5): Boolean {
|
||||
return execSu(command, timeoutSeconds)
|
||||
}
|
||||
|
||||
private fun execSu(shellLine: String, timeoutSeconds: Long): Boolean {
|
||||
return try {
|
||||
val process = ProcessBuilder("su", "-c", cmd)
|
||||
val process = ProcessBuilder("su", "-c", shellLine)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
@@ -122,12 +149,34 @@ object Root {
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}")
|
||||
Log.w(TAG, "runAsRoot('$shellLine') failed: ${e.message}")
|
||||
cachedGranted = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX-shell-style single-quote escape. Wraps in single quotes and
|
||||
* escapes embedded single quotes as ``'\''`` so shell metacharacters
|
||||
* inside [s] are inert.
|
||||
*/
|
||||
private fun shellQuote(s: String): String {
|
||||
if (s.isEmpty()) return "''"
|
||||
// Optimisation: if the string contains only safe characters,
|
||||
// skip the quoting overhead. The set is intentionally narrow —
|
||||
// notably `=` is excluded because an unquoted "FOO=bar" at the
|
||||
// start of a command would be parsed as a shell variable
|
||||
// assignment, not a literal arg. Quoting it forces literal use.
|
||||
if (s.all { it.isLetterOrDigit() || it in "_-./" }) return s
|
||||
val sb = StringBuilder(s.length + 2)
|
||||
sb.append('\'')
|
||||
for (ch in s) {
|
||||
if (ch == '\'') sb.append("'\\''") else sb.append(ch)
|
||||
}
|
||||
sb.append('\'')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** Forget the cached grant result — useful if Magisk permission was revoked. */
|
||||
@JvmStatic
|
||||
fun invalidateCache() {
|
||||
|
||||
@@ -38,8 +38,15 @@ class RootScreenrecord(
|
||||
private const val TAG = "RootScreenrecord"
|
||||
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
private const val INPUT_CHUNK = 64 * 1024
|
||||
// How long to back off when MediaCodec has no input buffer free.
|
||||
// 50 ms keeps the input pump from busy-spinning if the decoder
|
||||
// is stalled (codec init, severe stall, etc.).
|
||||
private const val NO_BUFFER_BACKOFF_MS = 5L
|
||||
}
|
||||
|
||||
// Instance is single-use: stop() permanently disposes it. Callers
|
||||
// wanting to restart the pipeline must construct a new instance —
|
||||
// see CaptureService.restartRootPipeline().
|
||||
@Volatile private var process: Process? = null
|
||||
private var decoder: MediaCodec? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
@@ -48,7 +55,22 @@ class RootScreenrecord(
|
||||
private var outputThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
private val framesDeliveredCounter = AtomicInteger(0)
|
||||
@Volatile private var stopped = false
|
||||
// disposed gates duplicate-stop calls only — not start() after
|
||||
// stop() (which is unsupported, see note above). Set at the START
|
||||
// of cleanup so a second concurrent stop() (rare under @Synchronized
|
||||
// but possible if a future caller drops it) doesn't re-run runCatching
|
||||
// blocks against already-released resources.
|
||||
@Volatile private var disposed = false
|
||||
// Guards process respawn vs. concurrent disposal. The input pump
|
||||
// can spawn a fresh `su -c screenrecord` after EOF; without this
|
||||
// lock, stop() could destroy the OLD process between spawn and
|
||||
// assignment, leaving the new one orphaned (GPU encoder leak).
|
||||
private val processLock = Any()
|
||||
|
||||
// Reusable RGBA buffer for ImageReader callbacks (single-threaded
|
||||
// reader callback). See ScreenCapture for the rationale: avoids
|
||||
// ~15 MB/s of per-frame garbage at 30 fps × 480×270×4 B.
|
||||
private val frameBuffer: ByteArray = ByteArray(width * height * 4)
|
||||
|
||||
/** Monotonic count of frames pushed to the Python bridge. */
|
||||
val framesDelivered: Int get() = framesDeliveredCounter.get()
|
||||
@@ -84,11 +106,11 @@ class RootScreenrecord(
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop everything and release resources. Idempotent. */
|
||||
/** Stop everything and release resources. Idempotent. Single-use: do not call start() again. */
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
// Order matters: signal first so worker loops drop out, then
|
||||
// stop the codec on the thread that created it (this one), then
|
||||
// join workers BEFORE releasing the codec/ImageReader they may
|
||||
@@ -107,7 +129,9 @@ class RootScreenrecord(
|
||||
// Best-effort: kill the screenrecord child before reaping `su`,
|
||||
// otherwise screenrecord can outlive su as an orphan and keep
|
||||
// the GPU encoder busy. Fire-and-forget; ignore failures.
|
||||
runCatching { Root.runAsRoot("pkill -TERM screenrecord", timeoutSeconds = 2) }
|
||||
runCatching {
|
||||
Root.runAsRoot(arrayOf("pkill", "-TERM", "screenrecord"), timeoutSeconds = 2)
|
||||
}
|
||||
|
||||
runCatching { decoder?.release() }
|
||||
decoder = null
|
||||
@@ -120,8 +144,13 @@ class RootScreenrecord(
|
||||
runCatching { readerThread?.join(500) }
|
||||
readerThread = null
|
||||
|
||||
runCatching { process?.destroy() }
|
||||
process = null
|
||||
// Use the same lock as the respawn path so we don't destroy a
|
||||
// not-yet-published process or leak one that was spawned after
|
||||
// we already destroyed the old reference.
|
||||
synchronized(processLock) {
|
||||
runCatching { process?.destroy() }
|
||||
process = null
|
||||
}
|
||||
|
||||
Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})")
|
||||
}
|
||||
@@ -131,7 +160,7 @@ class RootScreenrecord(
|
||||
readerThread = thread
|
||||
val handler = Handler(thread.looper)
|
||||
|
||||
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
|
||||
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 3)
|
||||
reader.setOnImageAvailableListener({ r ->
|
||||
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
|
||||
try {
|
||||
@@ -139,19 +168,17 @@ class RootScreenrecord(
|
||||
val buffer = plane.buffer
|
||||
val rowStride = plane.rowStride
|
||||
val pixelStride = plane.pixelStride
|
||||
val bytes = if (rowStride == width * pixelStride) {
|
||||
ByteArray(buffer.remaining()).also { buffer.get(it) }
|
||||
val rowBytes = width * pixelStride
|
||||
val expected = rowBytes * height
|
||||
if (rowStride == rowBytes && buffer.remaining() >= expected) {
|
||||
buffer.get(frameBuffer, 0, expected)
|
||||
} else {
|
||||
// Strip row padding — common when width isn't a multiple of 16.
|
||||
val rowBytes = width * pixelStride
|
||||
ByteArray(width * height * 4).also { out ->
|
||||
for (row in 0 until height) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(out, row * rowBytes, rowBytes)
|
||||
}
|
||||
for (row in 0 until height) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||
}
|
||||
}
|
||||
bridge.pushRootFrame(bytes, width, height)
|
||||
bridge.pushRootFrame(frameBuffer, width, height)
|
||||
framesDeliveredCounter.incrementAndGet()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Root frame delivery failed: ${e.message}")
|
||||
@@ -173,18 +200,26 @@ class RootScreenrecord(
|
||||
}
|
||||
|
||||
private fun spawnScreenrecord(): Process? {
|
||||
val cmd = buildString {
|
||||
append("screenrecord")
|
||||
append(" --output-format=h264")
|
||||
append(" --size=${width}x$height")
|
||||
append(" --bit-rate=$bitRate")
|
||||
// argv form — passes safely through Root.runAsRoot's shell-quote
|
||||
// logic so future changes to flag values can't introduce injection.
|
||||
val args = arrayOf(
|
||||
"screenrecord",
|
||||
"--output-format=h264",
|
||||
"--size=${width}x$height",
|
||||
"--bit-rate=$bitRate",
|
||||
// Time limit 0 isn't supported; the largest accepted is 180s.
|
||||
// We restart the process ourselves if it exits early.
|
||||
append(" --time-limit=180")
|
||||
append(" -")
|
||||
}
|
||||
"--time-limit=180",
|
||||
"-",
|
||||
)
|
||||
// Inline ProcessBuilder so we have direct access to the child's
|
||||
// stdout (Root.runAsRoot returns Boolean). We still pass args
|
||||
// unquoted because the entire array is a fixed program+flags
|
||||
// with no user-controlled content.
|
||||
return try {
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
|
||||
ProcessBuilder("su", "-c", args.joinToString(" "))
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
|
||||
null
|
||||
@@ -210,21 +245,56 @@ class RootScreenrecord(
|
||||
// exits cleanly we respawn so capture survives
|
||||
// long sessions instead of freezing after ~3min.
|
||||
Log.i(TAG, "screenrecord EOF — respawning")
|
||||
runCatching { process?.destroy() }
|
||||
synchronized(processLock) {
|
||||
runCatching { process?.destroy() }
|
||||
process = null
|
||||
}
|
||||
val next = spawnScreenrecord()
|
||||
if (next == null) {
|
||||
// Avoid a tight loop if `su` is suddenly unhappy.
|
||||
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
|
||||
continue@outer
|
||||
}
|
||||
process = next
|
||||
// Publish the new process under the lock so a
|
||||
// concurrent stop() either (a) sees no process,
|
||||
// tears down later, and lets us assign it for
|
||||
// the destroy on the NEXT stop call — or (b) sees
|
||||
// !running and we destroy the new process ourselves.
|
||||
val accepted = synchronized(processLock) {
|
||||
if (!running) {
|
||||
false
|
||||
} else {
|
||||
process = next
|
||||
true
|
||||
}
|
||||
}
|
||||
if (!accepted) {
|
||||
// running flipped false between EOF and now —
|
||||
// someone called stop(). Drop the new process
|
||||
// on the floor; the codec and output thread
|
||||
// are stop()'s responsibility (it's the only
|
||||
// writer to `running`, so we don't need to
|
||||
// tear them down here).
|
||||
runCatching { next.destroy() }
|
||||
break@outer
|
||||
}
|
||||
stream = next.inputStream
|
||||
continue@outer
|
||||
}
|
||||
var offset = 0
|
||||
while (offset < n && running) {
|
||||
val index = codec.dequeueInputBuffer(50_000)
|
||||
if (index < 0) continue
|
||||
if (index < 0) {
|
||||
// Codec is starved — back off briefly instead
|
||||
// of spinning. Without this, a stalled codec
|
||||
// burns 100% of one core hammering dequeue.
|
||||
try {
|
||||
Thread.sleep(NO_BUFFER_BACKOFF_MS)
|
||||
} catch (_: InterruptedException) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
val inputBuffer = codec.getInputBuffer(index) ?: continue
|
||||
inputBuffer.clear()
|
||||
val chunk = minOf(n - offset, inputBuffer.capacity())
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
@@ -8,24 +7,26 @@ import android.media.ImageReader
|
||||
import android.media.projection.MediaProjection
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Captures the Android screen via MediaProjection and feeds frames
|
||||
* to [PythonBridge].
|
||||
*
|
||||
* Frames are downscaled to [targetWidth] x [targetHeight] before
|
||||
* crossing the JNI boundary to minimize overhead. For LED ambient
|
||||
* lighting, even 480x270 contains far more data than needed.
|
||||
* Frames are downscaled to roughly [targetWidth] x [targetHeight] before
|
||||
* crossing the JNI boundary to minimize overhead. The actual capture
|
||||
* dimensions preserve the source screen's aspect ratio (snapped to even
|
||||
* pixels for codec friendliness) so non-16:9 displays don't get
|
||||
* squashed.
|
||||
*/
|
||||
class ScreenCapture(
|
||||
private val projection: MediaProjection,
|
||||
private val metrics: DisplayMetrics,
|
||||
private val bridge: PythonBridge,
|
||||
private val targetWidth: Int = 480,
|
||||
private val targetHeight: Int = 270,
|
||||
targetWidth: Int = 480,
|
||||
targetHeight: Int = 270,
|
||||
private val targetFps: Int = 30,
|
||||
private val onProjectionStopped: () -> Unit = {},
|
||||
) {
|
||||
@@ -34,13 +35,51 @@ class ScreenCapture(
|
||||
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
|
||||
}
|
||||
|
||||
// Snap to the source aspect ratio so we don't squash 21:9 / portrait
|
||||
// / rotated screens. Width is the budget; height follows.
|
||||
private val captureWidth: Int
|
||||
private val captureHeight: Int
|
||||
|
||||
init {
|
||||
val srcW = metrics.widthPixels.coerceAtLeast(1).toFloat()
|
||||
val srcH = metrics.heightPixels.coerceAtLeast(1).toFloat()
|
||||
val budget = targetWidth.coerceAtLeast(16)
|
||||
val aspect = srcW / srcH
|
||||
val w = budget
|
||||
val h = (w / aspect).toInt().coerceAtLeast(16)
|
||||
// Bias toward even dimensions — some encoders/ImageReaders are
|
||||
// unhappy with odd sizes when row strides come into play.
|
||||
captureWidth = (w and 1.inv()).coerceAtLeast(16)
|
||||
captureHeight = (h and 1.inv()).coerceAtLeast(16)
|
||||
if (captureWidth != targetWidth || captureHeight != targetHeight) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Capture size adjusted for ${srcW.toInt()}x${srcH.toInt()} " +
|
||||
"(${"%.2f".format(aspect)}:1) → ${captureWidth}x$captureHeight",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var captureThread: HandlerThread? = null
|
||||
private var captureHandler: Handler? = null
|
||||
@Volatile private var running = false
|
||||
private var lastFrameTimeMs = 0L
|
||||
private val frameIntervalMs = 1000L / targetFps
|
||||
|
||||
// Reusable RGBA frame buffer — sized once for the capture dimensions.
|
||||
// The capture handler is single-threaded so no synchronisation is
|
||||
// required around this buffer (each callback runs to completion
|
||||
// before the next is dispatched). Eliminates ~15 MB/s of per-frame
|
||||
// garbage at 30 fps × 480×270×4 B that previously caused GC pauses
|
||||
// on low-end TV boxes.
|
||||
private val frameBuffer: ByteArray = ByteArray(captureWidth * captureHeight * 4)
|
||||
|
||||
// Monotonic frame pacing. `nextFrameNanos` is the target render
|
||||
// time of the next frame; carrying it forward as an accumulator
|
||||
// avoids the integer-division drift the wall-clock version had
|
||||
// (e.g. 30 fps → 33 ms produced ~30.3 fps).
|
||||
private val frameIntervalNanos = (1_000_000_000L / targetFps.coerceAtLeast(1))
|
||||
private var nextFrameNanos = 0L
|
||||
|
||||
/**
|
||||
* Start capturing the screen.
|
||||
@@ -48,6 +87,7 @@ class ScreenCapture(
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
|
||||
|
||||
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
|
||||
captureHandler = Handler(captureThread!!.looper)
|
||||
@@ -56,28 +96,32 @@ class ScreenCapture(
|
||||
projection.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
Log.i(TAG, "MediaProjection stopped (external)")
|
||||
stop()
|
||||
// Notify the service so the foreground notification /
|
||||
// Python server get torn down too — otherwise a stale
|
||||
// "Running" notification lingers after the user taps
|
||||
// Android's system Cast/Screen-capture stop banner.
|
||||
// We're on captureHandler's thread here — calling stop()
|
||||
// directly would self-join captureThread (handler.join()
|
||||
// from inside the handler thread hangs until the join
|
||||
// timeout, then closes resources while we're STILL
|
||||
// inside this callback). Just flip `running` to halt
|
||||
// frame processing and hand off to the service; its
|
||||
// onDestroy will call stop() from the main thread,
|
||||
// which is safe to join captureThread from.
|
||||
running = false
|
||||
onProjectionStopped()
|
||||
}
|
||||
}, captureHandler)
|
||||
|
||||
imageReader = ImageReader.newInstance(
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
captureWidth,
|
||||
captureHeight,
|
||||
PixelFormat.RGBA_8888,
|
||||
2, // maxImages — double buffer
|
||||
3, // maxImages — small ring buffer; 3 is more forgiving than 2 under jitter
|
||||
)
|
||||
|
||||
imageReader?.setOnImageAvailableListener({ reader ->
|
||||
if (!running) return@setOnImageAvailableListener
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastFrameTimeMs < frameIntervalMs) {
|
||||
// Skip frame to maintain target FPS
|
||||
val now = SystemClock.elapsedRealtimeNanos()
|
||||
if (now < nextFrameNanos) {
|
||||
// Too early — drop this image to stay on cadence.
|
||||
reader.acquireLatestImage()?.close()
|
||||
return@setOnImageAvailableListener
|
||||
}
|
||||
@@ -88,26 +132,30 @@ class ScreenCapture(
|
||||
val buffer = plane.buffer
|
||||
val rowStride = plane.rowStride
|
||||
val pixelStride = plane.pixelStride
|
||||
val rowBytes = captureWidth * pixelStride
|
||||
val expected = rowBytes * captureHeight
|
||||
|
||||
// Handle row padding: rowStride may be > width * pixelStride
|
||||
val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
|
||||
// No padding — direct copy
|
||||
val bytes = ByteArray(buffer.remaining())
|
||||
buffer.get(bytes)
|
||||
bytes
|
||||
// Fill the reusable buffer. Two paths:
|
||||
// - rowStride == rowBytes: bulk get into the buffer
|
||||
// - rowStride > rowBytes: row-by-row copy stripping padding
|
||||
if (rowStride == rowBytes && buffer.remaining() >= expected) {
|
||||
buffer.get(frameBuffer, 0, expected)
|
||||
} else {
|
||||
// Strip row padding
|
||||
val rowBytes = targetWidth * pixelStride
|
||||
val bytes = ByteArray(targetWidth * targetHeight * 4)
|
||||
for (row in 0 until targetHeight) {
|
||||
for (row in 0 until captureHeight) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(bytes, row * rowBytes, rowBytes)
|
||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
|
||||
lastFrameTimeMs = now
|
||||
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
||||
|
||||
// Advance the pacing accumulator. If we fell badly behind
|
||||
// (long GC, JNI stall), snap forward to "now" instead of
|
||||
// accumulating a burst of catch-up frames.
|
||||
nextFrameNanos += frameIntervalNanos
|
||||
if (now - nextFrameNanos > frameIntervalNanos * 4) {
|
||||
nextFrameNanos = now + frameIntervalNanos
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Frame processing error: ${e.message}")
|
||||
} finally {
|
||||
@@ -117,8 +165,8 @@ class ScreenCapture(
|
||||
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
VIRTUAL_DISPLAY_NAME,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
captureWidth,
|
||||
captureHeight,
|
||||
metrics.densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
imageReader?.surface,
|
||||
@@ -126,7 +174,7 @@ class ScreenCapture(
|
||||
captureHandler,
|
||||
)
|
||||
|
||||
Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)")
|
||||
Log.i(TAG, "Screen capture started (${captureWidth}x${captureHeight} @ ${targetFps}fps)")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import com.hoho.android.usbserial.driver.UsbSerialPort
|
||||
import com.hoho.android.usbserial.driver.UsbSerialProber
|
||||
@@ -54,8 +55,23 @@ object UsbSerialBridge {
|
||||
if (!initialized.compareAndSet(false, true)) return
|
||||
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
val ourPackage = app.packageName
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
// Defence-in-depth: the receiver is registered as
|
||||
// RECEIVER_NOT_EXPORTED, but on pre-API-33 platforms
|
||||
// older Android versions historically defaulted to
|
||||
// exported. Also enforce the package check here so an
|
||||
// explicit-intent attack from another app on the device
|
||||
// is rejected even if the OS treats us as exported.
|
||||
if (intent.`package` != null && intent.`package` != ourPackage) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Ignoring USB permission broadcast from " +
|
||||
"package='${intent.`package`}' (not us)",
|
||||
)
|
||||
return
|
||||
}
|
||||
val granted = intent.getBooleanExtra(
|
||||
UsbManager.EXTRA_PERMISSION_GRANTED,
|
||||
false,
|
||||
@@ -69,13 +85,16 @@ object UsbSerialBridge {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
app.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
app.registerReceiver(receiver, filter)
|
||||
}
|
||||
// ContextCompat handles the RECEIVER_NOT_EXPORTED flag correctly
|
||||
// across all supported API levels (it's a no-op on platforms
|
||||
// where the flag doesn't exist, and explicit on API ≥33 where
|
||||
// Android enforces it).
|
||||
ContextCompat.registerReceiver(
|
||||
app,
|
||||
receiver,
|
||||
filter,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ctx(): Context =
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android TV launcher banner: 320x180 landscape.
|
||||
Shown on the leanback home row. The previous build reused the square
|
||||
launcher icon, which letterboxed badly. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:fillColor="#0d1117"
|
||||
android:pathData="M0,0 L320,0 L320,180 L0,180 Z" />
|
||||
<!-- Subtle teal glow top-left -->
|
||||
<path
|
||||
android:fillColor="#1A64ffda"
|
||||
android:pathData="M0,0 L160,0 L160,90 L0,90 Z" />
|
||||
<!-- Subtle purple glow bottom-right -->
|
||||
<path
|
||||
android:fillColor="#15bb86fc"
|
||||
android:pathData="M160,90 L320,90 L320,180 L160,180 Z" />
|
||||
<!-- TV body, centered -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M88,56 L196,56 Q204,56 204,64 L204,116 Q204,124 196,124 L88,124 Q80,124 80,116 L80,64 Q80,56 88,56 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M92,60 L192,60 Q196,60 196,64 L196,116 Q196,120 192,120 L92,120 Q88,120 88,116 L88,64 Q88,60 92,60 Z" />
|
||||
<!-- LED glow strips -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:fillAlpha="0.8"
|
||||
android:pathData="M94,50 L190,50 L190,54 L94,54 Z" />
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M72,62 L76,62 L76,118 L72,118 Z" />
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M208,62 L212,62 L212,118 L208,118 Z" />
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M94,126 L190,126 L190,130 L94,130 Z" />
|
||||
<!-- Wordmark "LedGrab" — drawn as paths so we don't depend on the
|
||||
system font cache being warm at TV launch. -->
|
||||
<!-- L -->
|
||||
<path android:fillColor="#64ffda"
|
||||
android:pathData="M222,72 L228,72 L228,100 L240,100 L240,106 L222,106 Z" />
|
||||
<!-- e -->
|
||||
<path android:fillColor="#e6edf3"
|
||||
android:pathData="M244,82 L260,82 Q264,82 264,86 L264,94 L250,94 L250,100 L262,100 L262,106 L246,106 Q244,106 244,104 Z M250,86 L250,90 L258,90 L258,86 Z" />
|
||||
<!-- d -->
|
||||
<path android:fillColor="#e6edf3"
|
||||
android:pathData="M266,72 L272,72 L272,82 L284,82 Q286,82 286,84 L286,106 L268,106 Q266,106 266,104 Z M272,88 L272,100 L280,100 L280,88 Z" />
|
||||
</vector>
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static fallback for the status dot. The animated version
|
||||
(animated_status_dot.xml) is used at runtime; this is what
|
||||
XML rendering tools show in the editor. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/green_status" />
|
||||
<size android:width="18dp" android:height="18dp" />
|
||||
</shape>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Monochrome status-bar icon. Android requires white-on-transparent for
|
||||
notification icons since API 21 - reusing the colored launcher would
|
||||
render as a gray blob. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M5,7 L19,7 Q20,7 20,8 L20,16 Q20,17 19,17 L5,17 Q4,17 4,16 L4,8 Q4,7 5,7 Z M5.5,8.5 L5.5,15.5 L18.5,15.5 L18.5,8.5 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M10,17 L10,18.5 L14,18.5 L14,17 Z M9,19 L15,19 L15,20 L9,20 Z" />
|
||||
<!-- LED glow strips around the TV (bright dots) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M6,5.5 L18,5.5 L18,6.5 L6,6.5 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Splash screen icon (API 31+ uses a 1:1 vector inside a 240dp circle).
|
||||
The SplashScreen API masks this with a circle automatically. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="240dp"
|
||||
android:height="240dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
|
||||
<!-- LED glow strips, brighter on splash for impact -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M44,72 L44,78 L64,78 L64,72" />
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
|
||||
</vector>
|
||||
@@ -32,16 +32,28 @@
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.08"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:fontFamily="sans-serif-light" />
|
||||
android:fontFamily="sans-serif" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:id="@+id/tagline_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/tagline"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="28sp"
|
||||
android:layout_marginBottom="64dp" />
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<!-- Transient status (root probing / permission denial). Always
|
||||
present so the layout doesn't reflow when text appears. -->
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="20sp"
|
||||
android:layout_marginBottom="32dp"
|
||||
tools:text="Checking root access…" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/toggle_button"
|
||||
@@ -51,7 +63,8 @@
|
||||
android:text="@string/btn_start"
|
||||
android:textSize="22sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
android:focusableInTouchMode="true"
|
||||
android:nextFocusDown="@+id/autostart_check" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/autostart_check"
|
||||
@@ -63,10 +76,11 @@
|
||||
android:textSize="20sp"
|
||||
android:buttonTint="@color/teal_accent"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
android:focusableInTouchMode="true"
|
||||
android:nextFocusUp="@id/toggle_button" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Version at bottom -->
|
||||
<!-- Version at bottom (always visible — looks polished on TV idle). -->
|
||||
<TextView
|
||||
android:id="@+id/version_text"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -77,115 +91,13 @@
|
||||
android:textSize="18sp"
|
||||
tools:text="v0.1.0" />
|
||||
|
||||
<!-- RUNNING STATE -->
|
||||
<LinearLayout
|
||||
android:id="@+id/running_panel"
|
||||
<!-- RUNNING STATE — deferred-inflate via ViewStub so first paint is
|
||||
cheaper and the inflater doesn't measure two competing layouts. -->
|
||||
<ViewStub
|
||||
android:id="@+id/running_panel_stub"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="120dp"
|
||||
android:paddingEnd="120dp"
|
||||
android:paddingTop="80dp"
|
||||
android:paddingBottom="80dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- Left: status + URL + stop -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingEnd="64dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="32dp">
|
||||
|
||||
<View
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:background="@drawable/bg_status_dot"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/status_running"
|
||||
android:textColor="@color/green_status"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.05" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/label_web_ui"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/url_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/teal_accent"
|
||||
android:textSize="30sp"
|
||||
android:maxLines="1"
|
||||
android:textStyle="bold"
|
||||
android:background="@drawable/bg_url_chip"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:layout_marginBottom="56dp"
|
||||
tools:text="http://192.168.1.5:8080" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/stop_button_running"
|
||||
style="@style/Widget.LedGrab.Button.Secondary"
|
||||
android:layout_width="240dp"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/btn_stop"
|
||||
android:textSize="20sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: QR code -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_qr_container"
|
||||
android:padding="20dp"
|
||||
android:layout_marginBottom="20dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qr_image"
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="280dp"
|
||||
android:contentDescription="@string/qr_description"
|
||||
android:scaleType="fitXY" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_to_configure"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:gravity="center" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
android:inflatedId="@+id/running_panel"
|
||||
android:layout="@layout/panel_running"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- RUNNING STATE -->
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="120dp"
|
||||
android:paddingEnd="120dp"
|
||||
android:paddingTop="80dp"
|
||||
android:paddingBottom="80dp">
|
||||
|
||||
<!-- Left: status + URL + stop -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingEnd="64dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="32dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/status_dot"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:background="@drawable/bg_status_dot"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/status_running"
|
||||
android:textColor="@color/green_status"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.05" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/label_web_ui"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/url_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/teal_accent"
|
||||
android:textSize="30sp"
|
||||
android:maxLines="1"
|
||||
android:textStyle="bold"
|
||||
android:background="@drawable/bg_url_chip"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:layout_marginBottom="56dp"
|
||||
tools:text="http://192.168.1.5:8080" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/stop_button_running"
|
||||
style="@style/Widget.LedGrab.Button.Secondary"
|
||||
android:layout_width="240dp"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/btn_stop"
|
||||
android:textSize="20sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:nextFocusUp="@id/stop_button_running"
|
||||
android:nextFocusDown="@id/stop_button_running" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: QR code + fallback hint -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_qr_container"
|
||||
android:padding="20dp"
|
||||
android:layout_marginBottom="20dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qr_image"
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="280dp"
|
||||
android:contentDescription="@string/qr_description"
|
||||
android:scaleType="fitXY" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_to_configure"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:gravity="center" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_fallback_hint"
|
||||
android:textColor="@color/text_hint"
|
||||
android:textSize="14sp"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="6dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -3,12 +3,26 @@
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Фоновая подсветка для телевизора</string>
|
||||
<string name="btn_start">Начать захват</string>
|
||||
<string name="btn_starting">Запуск…</string>
|
||||
<string name="btn_stop">Стоп</string>
|
||||
<string name="status_running">Работает</string>
|
||||
<string name="status_checking_root">Проверка root-доступа…</string>
|
||||
<string name="status_permission_denied">Доступ запрещён — для захвата экрана требуется разрешение</string>
|
||||
<string name="status_no_network">Нет сети — подключите Wi-Fi или Ethernet</string>
|
||||
<string name="label_web_ui">Адрес веб-интерфейса</string>
|
||||
<string name="scan_to_configure">Сканируйте для настройки</string>
|
||||
<string name="scan_fallback_hint">или откройте этот адрес с любого устройства в сети</string>
|
||||
<string name="qr_description">QR-код для веб-интерфейса</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">Запускать при загрузке (только с root)</string>
|
||||
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string>
|
||||
<string name="fatal_title">Не удалось запустить LedGrab</string>
|
||||
<string name="fatal_body_prefix">Ошибка инициализации Python:</string>
|
||||
<string name="fatal_copy_log">Скопировать журнал</string>
|
||||
<string name="fatal_show_details">Показать подробности</string>
|
||||
<string name="fatal_hide_details">Скрыть подробности</string>
|
||||
<string name="notification_channel_name">Захват LedGrab</string>
|
||||
<string name="notification_channel_description">Отображается, пока LedGrab захватывает экран.</string>
|
||||
<string name="notification_title">LedGrab работает</string>
|
||||
<string name="notification_text">Веб-интерфейс: %1$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,12 +3,26 @@
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">电视氛围灯光</string>
|
||||
<string name="btn_start">开始捕获</string>
|
||||
<string name="btn_starting">正在启动…</string>
|
||||
<string name="btn_stop">停止</string>
|
||||
<string name="status_running">运行中</string>
|
||||
<string name="status_checking_root">正在检查 root 权限…</string>
|
||||
<string name="status_permission_denied">权限被拒绝 — 屏幕捕获需要授权</string>
|
||||
<string name="status_no_network">无网络 — 请连接 Wi-Fi 或以太网</string>
|
||||
<string name="label_web_ui">Web界面地址</string>
|
||||
<string name="scan_to_configure">扫码配置</string>
|
||||
<string name="scan_fallback_hint">或在同一网络的任何设备上访问上方网址</string>
|
||||
<string name="qr_description">Web界面二维码</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">开机自启(仅限 root)</string>
|
||||
<string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string>
|
||||
<string name="fatal_title">LedGrab 启动失败</string>
|
||||
<string name="fatal_body_prefix">Python 运行时初始化失败:</string>
|
||||
<string name="fatal_copy_log">复制日志</string>
|
||||
<string name="fatal_show_details">显示详情</string>
|
||||
<string name="fatal_hide_details">隐藏详情</string>
|
||||
<string name="notification_channel_name">LedGrab 屏幕捕获</string>
|
||||
<string name="notification_channel_description">LedGrab 捕获屏幕时显示。</string>
|
||||
<string name="notification_title">LedGrab 运行中</string>
|
||||
<string name="notification_text">Web界面:%1$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,12 +3,26 @@
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Ambient lighting for your TV</string>
|
||||
<string name="btn_start">Start Capture</string>
|
||||
<string name="btn_starting">Starting…</string>
|
||||
<string name="btn_stop">Stop</string>
|
||||
<string name="status_running">Running</string>
|
||||
<string name="status_checking_root">Checking root access…</string>
|
||||
<string name="status_permission_denied">Permission denied — screen capture requires authorization</string>
|
||||
<string name="status_no_network">No network — connect Wi-Fi or Ethernet</string>
|
||||
<string name="label_web_ui">Web UI address</string>
|
||||
<string name="scan_to_configure">Scan to configure</string>
|
||||
<string name="scan_fallback_hint">or visit the URL above on any device on this network</string>
|
||||
<string name="qr_description">QR code for web UI</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">Start on boot (root only)</string>
|
||||
<string name="autostart_unavailable">Start on boot — unavailable (root required)</string>
|
||||
<string name="fatal_title">LedGrab failed to start</string>
|
||||
<string name="fatal_body_prefix">Python runtime initialization failed:</string>
|
||||
<string name="fatal_copy_log">Copy log</string>
|
||||
<string name="fatal_show_details">Show details</string>
|
||||
<string name="fatal_hide_details">Hide details</string>
|
||||
<string name="notification_channel_name">LedGrab capture</string>
|
||||
<string name="notification_channel_description">Shows while LedGrab is capturing the screen.</string>
|
||||
<string name="notification_title">LedGrab Running</string>
|
||||
<string name="notification_text">Web UI: %1$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
<item name="android:colorControlActivated">@color/teal_accent</item>
|
||||
</style>
|
||||
|
||||
<!-- Splash screen theme. Compatible across API levels via the
|
||||
androidx.core:core-splashscreen library. On API 31+ the system
|
||||
splash uses the foreground icon; on older versions the launch
|
||||
theme just paints the navy background, which is harmless. -->
|
||||
<style name="Theme.LedGrab.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/bg_navy</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.LedGrab</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
|
||||
<item name="android:background">@drawable/bg_button_primary</item>
|
||||
<item name="android:textColor">@color/bg_navy</item>
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
|
||||
brokers on the local network via plain HTTP/UDP. Cleartext traffic
|
||||
must be allowed for these connections to work on Android 9+.
|
||||
LedGrab is a LAN-only app:
|
||||
- Inbound: web UI / API on the device (HTTP, port 8080)
|
||||
- Outbound: WLED HTTP/UDP, Home Assistant, MQTT brokers, mDNS
|
||||
|
||||
All of these are plaintext on the local network. Android's network
|
||||
security config doesn't support CIDR allowlists, so we cannot
|
||||
restrict cleartext to RFC1918 ranges declaratively — we have to
|
||||
permit cleartext base-wide.
|
||||
|
||||
Defence-in-depth that ACTUALLY mitigates this:
|
||||
1. Inbound: the FastAPI server in this app rejects non-loopback
|
||||
requests when no API key is configured (see ledgrab.api.auth).
|
||||
The Android launcher auto-generates an API key on first run
|
||||
(see ApiKeyManager.kt) and injects it via the
|
||||
LEDGRAB_AUTH__API_KEYS env var before uvicorn starts. The
|
||||
user's phone receives the key by scanning the QR, which
|
||||
embeds the key as a URL fragment (never logged server-side).
|
||||
2. Outbound: targets are validated by net_classify in the Python
|
||||
layer (LAN-only HTTP, SSRF-safe).
|
||||
|
||||
DO NOT remove the cleartext permission without first migrating
|
||||
every LAN peer to HTTPS — most WLED firmware, mDNS, and the LAN
|
||||
HTTP server itself rely on this flag.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Cross-compile pydantic-core for Android across all three ABIs:
|
||||
# arm64-v8a (primary — real TV hardware)
|
||||
# x86_64 (modern emulators)
|
||||
# x86 (legacy emulators)
|
||||
# Cross-compile pydantic-core for Android across all supported ABIs:
|
||||
# arm64-v8a (primary — modern TV hardware)
|
||||
# x86_64 (modern emulators)
|
||||
# x86 (legacy emulators)
|
||||
# armeabi-v7a (32-bit ARMv7 — older cheap TV boxes like X96 mini, MeCool)
|
||||
#
|
||||
# Outputs wheels into android/wheels/. Wheels are linked against the real
|
||||
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
|
||||
# memory/project_android_app.md for the incident notes).
|
||||
#
|
||||
# Prerequisites (on host):
|
||||
# - Rust + cargo (rustup) with targets: aarch64/x86_64/i686-linux-android
|
||||
# - Rust + cargo (rustup) with targets:
|
||||
# aarch64/x86_64/i686/armv7a-linux-android(eabi)
|
||||
# - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*)
|
||||
# - Python 3.11 (matches Chaquopy's embedded version)
|
||||
# - maturin (pip install maturin)
|
||||
@@ -19,9 +21,10 @@
|
||||
# core dependency version changes.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-pydantic-core.sh # build all three ABIs
|
||||
# ./build-pydantic-core.sh arm64 # build a single ABI
|
||||
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
|
||||
# ./build-pydantic-core.sh # build all 4 ABIs
|
||||
# ./build-pydantic-core.sh arm64 # build a single ABI
|
||||
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
|
||||
# ./build-pydantic-core.sh armv7 # 32-bit ARM only
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
@@ -91,21 +94,23 @@ fi
|
||||
# ── ABI table ───────────────────────────────────────────────────────
|
||||
# Columns: short_name rust_target clang_prefix sysconfig_dir
|
||||
ABI_TABLE=(
|
||||
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
|
||||
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
|
||||
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
|
||||
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
|
||||
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
|
||||
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
|
||||
"armv7 armv7-linux-androideabi armv7a-linux-androideabi${API_LEVEL} cross-sysconfig-armv7"
|
||||
)
|
||||
|
||||
declare -A ABI_TAG_MAP=(
|
||||
[arm64]="arm64_v8a"
|
||||
[x86_64]="x86_64"
|
||||
[x86]="x86"
|
||||
[armv7]="armeabi_v7a"
|
||||
)
|
||||
|
||||
# ── Select which ABIs to build ──────────────────────────────────────
|
||||
SELECTED=("$@")
|
||||
if [ ${#SELECTED[@]} -eq 0 ]; then
|
||||
SELECTED=(arm64 x86_64 x86)
|
||||
SELECTED=(arm64 x86_64 x86 armv7)
|
||||
fi
|
||||
|
||||
# ── Ensure rust targets are installed ───────────────────────────────
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Generate LedGrab app icon assets.
|
||||
|
||||
Concept: "Spectrum Aperture" — a rounded-square frame (the screen/display)
|
||||
traced by a continuous RGB color-wheel stroke (the bias-light LED strip),
|
||||
on a near-black canvas with a soft chromatic bloom behind it.
|
||||
|
||||
Outputs:
|
||||
server/src/ledgrab/static/icons/icon-512.png (standard, opaque vignette bg)
|
||||
server/src/ledgrab/static/icons/icon-192.png (downscale of 512)
|
||||
server/src/ledgrab/static/icons/icon-512-maskable.png (safe-area padded, opaque)
|
||||
server/src/ledgrab/static/icons/icon-tray.png (256, transparent bg, frame + glow)
|
||||
server/src/ledgrab/static/icons/icon.ico (16/24/32/48/64/128/256)
|
||||
|
||||
Run from repo root:
|
||||
py -3.13 build/generate_icon.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
# ── Tunables ────────────────────────────────────────────────────────────
|
||||
SUPERSAMPLE = 4 # render at 4x and downsample for crispness
|
||||
BASE = 1024 # logical canvas size
|
||||
HQ = BASE * SUPERSAMPLE # render canvas
|
||||
|
||||
BG_TOP = (12, 14, 22) # near-black, faint cool tint
|
||||
BG_BOTTOM = (6, 7, 12) # darker at edges (vignette feel)
|
||||
|
||||
FRAME_INSET = 0.18 # margin from canvas edge to frame (fraction)
|
||||
FRAME_RADIUS = 0.22 # corner radius (fraction of frame side)
|
||||
FRAME_STROKE = 0.085 # stroke width (fraction of canvas)
|
||||
BLOOM_OPACITY = 0.62 # outer bloom strength (0–1)
|
||||
INNER_GLOW_OPACITY = 0.38 # inner chromatic reflection strength
|
||||
|
||||
# Hue rotation offset so red sits at the top
|
||||
HUE_OFFSET = -90.0 # degrees (negative = counter-clockwise shift)
|
||||
|
||||
|
||||
def lerp(a: float, b: float, t: float) -> float:
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def hue_to_rgb(hue_deg: float) -> tuple[int, int, int]:
|
||||
"""Bright, slightly desaturated spectral color (LED-like)."""
|
||||
h = (hue_deg % 360) / 360.0
|
||||
r, g, b = colorsys.hls_to_rgb(h, 0.58, 0.92)
|
||||
return int(r * 255), int(g * 255), int(b * 255)
|
||||
|
||||
|
||||
def vignette_background(size: int) -> Image.Image:
|
||||
"""Dark canvas with a soft radial vignette + faint scanline noise."""
|
||||
img = Image.new("RGB", (size, size), BG_TOP)
|
||||
px = img.load()
|
||||
cx, cy = size / 2, size / 2
|
||||
max_r = math.hypot(cx, cy)
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
d = math.hypot(x - cx, y - cy) / max_r
|
||||
t = min(1.0, d**1.6)
|
||||
px[x, y] = (
|
||||
int(lerp(BG_TOP[0], BG_BOTTOM[0], t)),
|
||||
int(lerp(BG_TOP[1], BG_BOTTOM[1], t)),
|
||||
int(lerp(BG_TOP[2], BG_BOTTOM[2], t)),
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
def draw_chromatic_bloom(size: int) -> Image.Image:
|
||||
"""Soft, large chromatic glow behind the frame — the bias-light effect."""
|
||||
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
|
||||
cx, cy = size / 2, size / 2
|
||||
radius = size * 0.36
|
||||
blob_r = int(size * 0.30)
|
||||
n_blobs = 24
|
||||
|
||||
for i in range(n_blobs):
|
||||
a = i / n_blobs * 360.0
|
||||
bx = cx + math.cos(math.radians(a - 90)) * radius
|
||||
by = cy + math.sin(math.radians(a - 90)) * radius
|
||||
r, g, b = hue_to_rgb(a + HUE_OFFSET)
|
||||
alpha = int(255 * BLOOM_OPACITY * 0.55)
|
||||
draw.ellipse(
|
||||
(bx - blob_r, by - blob_r, bx + blob_r, by + blob_r),
|
||||
fill=(r, g, b, alpha),
|
||||
)
|
||||
|
||||
# Heavy blur → continuous, dreamy halo
|
||||
layer = layer.filter(ImageFilter.GaussianBlur(radius=size * 0.10))
|
||||
return layer
|
||||
|
||||
|
||||
def rounded_rect_mask(size: int, inset: int, radius: int, stroke: int) -> Image.Image:
|
||||
"""L-mode mask of a rounded-rect ring (the frame stroke region)."""
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
box_outer = (inset, inset, size - inset, size - inset)
|
||||
box_inner = (
|
||||
inset + stroke,
|
||||
inset + stroke,
|
||||
size - inset - stroke,
|
||||
size - inset - stroke,
|
||||
)
|
||||
r_outer = radius
|
||||
r_inner = max(0, radius - stroke)
|
||||
draw.rounded_rectangle(box_outer, radius=r_outer, fill=255)
|
||||
draw.rounded_rectangle(box_inner, radius=r_inner, fill=0)
|
||||
return mask
|
||||
|
||||
|
||||
def draw_spectrum_frame(size: int) -> Image.Image:
|
||||
"""Draw the rounded-square frame stroke filled with a hue-rotation gradient.
|
||||
|
||||
Strategy: paint a full-canvas angular hue gradient (centered), then
|
||||
clip it with the rounded-ring mask. This guarantees a continuous,
|
||||
seam-free color flow around the entire frame.
|
||||
"""
|
||||
cx, cy = size / 2, size / 2
|
||||
|
||||
gradient = Image.new("RGB", (size, size), (0, 0, 0))
|
||||
gpx = gradient.load()
|
||||
for y in range(size):
|
||||
dy = y - cy
|
||||
for x in range(size):
|
||||
dx = x - cx
|
||||
ang = math.degrees(math.atan2(dy, dx)) + 90.0 # 0° = top
|
||||
r, g, b = hue_to_rgb(ang + HUE_OFFSET)
|
||||
gpx[x, y] = (r, g, b)
|
||||
|
||||
inset = int(size * FRAME_INSET)
|
||||
frame_side = size - 2 * inset
|
||||
stroke = int(size * FRAME_STROKE)
|
||||
radius = int(frame_side * FRAME_RADIUS)
|
||||
|
||||
mask = rounded_rect_mask(size, inset, radius, stroke)
|
||||
|
||||
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
out.paste(gradient, (0, 0), mask)
|
||||
return out
|
||||
|
||||
|
||||
def draw_inner_screen(size: int) -> Image.Image:
|
||||
"""Subtle dark rounded square inside the frame, with faint chromatic
|
||||
inner reflection along the edges — like a screen catching ambient light."""
|
||||
inset = int(size * FRAME_INSET)
|
||||
stroke = int(size * FRAME_STROKE)
|
||||
frame_side = size - 2 * inset
|
||||
radius = int(frame_side * FRAME_RADIUS)
|
||||
|
||||
pad = int(stroke * 0.35)
|
||||
box = (
|
||||
inset + stroke + pad,
|
||||
inset + stroke + pad,
|
||||
size - inset - stroke - pad,
|
||||
size - inset - stroke - pad,
|
||||
)
|
||||
r_inner = max(0, radius - stroke - pad)
|
||||
|
||||
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
# Dark fill, very slight cool tint
|
||||
draw.rounded_rectangle(box, radius=r_inner, fill=(10, 12, 18, 255))
|
||||
|
||||
# Inner chromatic glow: same spectrum, very soft, clipped to the screen
|
||||
bloom = draw_chromatic_bloom(size)
|
||||
screen_mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(screen_mask).rounded_rectangle(box, radius=r_inner, fill=255)
|
||||
|
||||
bloom_alpha = bloom.split()[-1].point(lambda v: int(v * INNER_GLOW_OPACITY))
|
||||
bloom.putalpha(bloom_alpha)
|
||||
|
||||
masked_bloom = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
masked_bloom.paste(bloom, (0, 0), screen_mask)
|
||||
layer.alpha_composite(masked_bloom)
|
||||
|
||||
# Faint highlight glint top-left
|
||||
glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
gdraw = ImageDraw.Draw(glint)
|
||||
glint_box = (
|
||||
box[0] + int(frame_side * 0.04),
|
||||
box[1] + int(frame_side * 0.04),
|
||||
box[0] + int(frame_side * 0.42),
|
||||
box[1] + int(frame_side * 0.18),
|
||||
)
|
||||
gdraw.rounded_rectangle(glint_box, radius=int(frame_side * 0.05), fill=(255, 255, 255, 22))
|
||||
glint = glint.filter(ImageFilter.GaussianBlur(radius=size * 0.012))
|
||||
masked_glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
masked_glint.paste(glint, (0, 0), screen_mask)
|
||||
layer.alpha_composite(masked_glint)
|
||||
|
||||
return layer
|
||||
|
||||
|
||||
def add_outer_frame_glow(frame_rgba: Image.Image) -> Image.Image:
|
||||
"""Take the spectrum frame and produce a blurred, brightened copy for glow."""
|
||||
glow = frame_rgba.copy()
|
||||
# Slightly inflate brightness for glow
|
||||
r, g, b, a = glow.split()
|
||||
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.85)))))
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(radius=glow.width * 0.025))
|
||||
return glow
|
||||
|
||||
|
||||
def render_tray(size: int) -> Image.Image:
|
||||
"""Render a tray-optimised icon: transparent background, bolder frame,
|
||||
tight outer glow. Designed to read clearly at 16–32 px on top of any
|
||||
taskbar color."""
|
||||
hq = size * SUPERSAMPLE
|
||||
|
||||
# Pull the frame inward a touch and beef up the stroke so it reads at 16 px.
|
||||
global FRAME_INSET, FRAME_STROKE
|
||||
saved_inset, saved_stroke = FRAME_INSET, FRAME_STROKE
|
||||
FRAME_INSET = 0.13
|
||||
FRAME_STROKE = 0.115
|
||||
try:
|
||||
frame = draw_spectrum_frame(hq)
|
||||
finally:
|
||||
FRAME_INSET, FRAME_STROKE = saved_inset, saved_stroke
|
||||
|
||||
# Tight, bright glow that doesn't bleed past the tray cell.
|
||||
glow = frame.copy()
|
||||
r, g, b, a = glow.split()
|
||||
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.95)))))
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(radius=hq * 0.012))
|
||||
|
||||
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
|
||||
canvas.alpha_composite(glow)
|
||||
canvas.alpha_composite(frame)
|
||||
|
||||
return canvas.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def render(size: int, *, maskable: bool = False) -> Image.Image:
|
||||
"""Render the full icon at the given size."""
|
||||
hq = size * SUPERSAMPLE
|
||||
|
||||
if maskable:
|
||||
# Maskable: pad inward so the entire icon survives a circular crop.
|
||||
# We render the standard composition at 80% of canvas size, centered.
|
||||
bg = Image.new("RGB", (hq, hq), BG_BOTTOM).convert("RGBA")
|
||||
bg.paste(vignette_background(hq), (0, 0))
|
||||
|
||||
inner = render(size, maskable=False).resize((int(hq * 0.78), int(hq * 0.78)), Image.LANCZOS)
|
||||
# Strip the bg from the inner render: composite the spectrum
|
||||
# parts on top of our maskable background.
|
||||
ox = (hq - inner.width) // 2
|
||||
oy = (hq - inner.height) // 2
|
||||
bg.alpha_composite(inner, (ox, oy))
|
||||
return bg.resize((size, size), Image.LANCZOS)
|
||||
|
||||
bg = vignette_background(hq).convert("RGBA")
|
||||
bloom = draw_chromatic_bloom(hq)
|
||||
frame = draw_spectrum_frame(hq)
|
||||
frame_glow = add_outer_frame_glow(frame)
|
||||
inner_screen = draw_inner_screen(hq)
|
||||
|
||||
# Composite order: bg → bloom → frame_glow → inner_screen → frame
|
||||
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
|
||||
canvas.alpha_composite(bg)
|
||||
canvas.alpha_composite(bloom)
|
||||
canvas.alpha_composite(frame_glow)
|
||||
canvas.alpha_composite(inner_screen)
|
||||
canvas.alpha_composite(frame)
|
||||
|
||||
return canvas.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
targets = [
|
||||
repo_root / "server" / "src" / "ledgrab" / "static" / "icons",
|
||||
repo_root
|
||||
/ "android"
|
||||
/ "app"
|
||||
/ "build"
|
||||
/ "python"
|
||||
/ "sources"
|
||||
/ "debug"
|
||||
/ "ledgrab"
|
||||
/ "static"
|
||||
/ "icons",
|
||||
]
|
||||
|
||||
print("Rendering 1024 master...")
|
||||
master = render(1024, maskable=False)
|
||||
|
||||
print("Rendering maskable 1024 master...")
|
||||
maskable_master = render(1024, maskable=True)
|
||||
|
||||
print("Rendering tray 512 master (transparent bg)...")
|
||||
tray_master = render_tray(512)
|
||||
|
||||
for icons_dir in targets:
|
||||
if not icons_dir.exists():
|
||||
print(f" skip (missing): {icons_dir}")
|
||||
continue
|
||||
|
||||
out_512 = icons_dir / "icon-512.png"
|
||||
out_192 = icons_dir / "icon-192.png"
|
||||
out_mask = icons_dir / "icon-512-maskable.png"
|
||||
out_tray = icons_dir / "icon-tray.png"
|
||||
out_ico = icons_dir / "icon.ico"
|
||||
|
||||
master.resize((512, 512), Image.LANCZOS).save(out_512, "PNG", optimize=True)
|
||||
master.resize((192, 192), Image.LANCZOS).save(out_192, "PNG", optimize=True)
|
||||
maskable_master.resize((512, 512), Image.LANCZOS).save(out_mask, "PNG", optimize=True)
|
||||
tray_master.save(out_tray, "PNG", optimize=True)
|
||||
|
||||
# Pre-resize each frame from the 1024 master for maximum crispness.
|
||||
# Pass them via the `sizes` arg so Pillow embeds every variant.
|
||||
ico_sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
|
||||
# Use the tray (transparent-bg) variant for ICO frames so the file/
|
||||
# taskbar icon doesn't show a dark tile against light backgrounds.
|
||||
ico_source = tray_master.resize((256, 256), Image.LANCZOS)
|
||||
ico_source.save(out_ico, format="ICO", sizes=ico_sizes)
|
||||
|
||||
print(f" wrote: {icons_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ledgrab"
|
||||
version = "0.7.0"
|
||||
version = "0.8.1"
|
||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
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.7.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
|
||||
@@ -49,7 +50,8 @@ from ledgrab.utils.win_shutdown import WindowsShutdownGuard # noqa: E402
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png"
|
||||
_ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-tray.png"
|
||||
_ICON_FALLBACK_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png"
|
||||
|
||||
|
||||
def _run_server(server: uvicorn.Server) -> None:
|
||||
@@ -107,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
|
||||
@@ -154,8 +167,9 @@ def main() -> None:
|
||||
).start()
|
||||
|
||||
# Tray on main thread (blocking)
|
||||
tray_icon = _ICON_PATH if _ICON_PATH.exists() else _ICON_FALLBACK_PATH
|
||||
tray = TrayManager(
|
||||
icon_path=_ICON_PATH,
|
||||
icon_path=tray_icon,
|
||||
port=config.server.port,
|
||||
on_exit=lambda: _request_shutdown(server),
|
||||
)
|
||||
@@ -163,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:
|
||||
|
||||
@@ -6,6 +6,7 @@ inside an Android application. Sets up Android-specific paths
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from typing import Any
|
||||
@@ -15,7 +16,7 @@ _server: Any | None = None # uvicorn.Server
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) -> None:
|
||||
"""Start the LedGrab uvicorn server.
|
||||
|
||||
Called from Kotlin's ``PythonBridge.startServer()``. This function
|
||||
@@ -26,6 +27,11 @@ def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
data_dir: Android app-private files directory
|
||||
(e.g. ``/data/data/com.ledgrab.android/files``).
|
||||
port: HTTP port for the web UI / API.
|
||||
api_key: Optional Bearer token to enable LAN auth. When set,
|
||||
published as ``LEDGRAB_AUTH__API_KEYS={"android":<key>}``
|
||||
so the server's auth gate accepts LAN requests carrying
|
||||
``Authorization: Bearer <key>``. When None, the server
|
||||
falls back to its default (loopback-only).
|
||||
"""
|
||||
# ── Configure paths before any LedGrab imports ──────────────
|
||||
os.makedirs(os.path.join(data_dir, "data"), exist_ok=True)
|
||||
@@ -41,6 +47,14 @@ def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
os.environ["LEDGRAB_SERVER__HOST"] = "0.0.0.0"
|
||||
os.environ["LEDGRAB_SERVER__PORT"] = str(port)
|
||||
|
||||
# Provision LAN auth when the Kotlin launcher supplied a key. The
|
||||
# config layer (pydantic-settings) parses ``LEDGRAB_AUTH__API_KEYS``
|
||||
# as JSON when the value starts with `{`. We use a dict so the
|
||||
# rest of the codebase sees a labelled key just like the YAML
|
||||
# config form (api_keys: {android: ...}).
|
||||
if api_key:
|
||||
os.environ["LEDGRAB_AUTH__API_KEYS"] = json.dumps({"android": api_key})
|
||||
|
||||
# ── Now safe to import LedGrab ──────────────────────────────
|
||||
import uvicorn # noqa: E402
|
||||
|
||||
@@ -50,10 +64,27 @@ def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
logger = get_logger(__name__)
|
||||
logger.info("LedGrab Android: starting server on port %d", port)
|
||||
logger.info("Data directory: %s", data_dir)
|
||||
if api_key:
|
||||
logger.info("LedGrab Android: API key auth enabled (label=android)")
|
||||
else:
|
||||
logger.warning("LedGrab Android: no API key — LAN requests will be rejected")
|
||||
|
||||
from ledgrab.config import get_config # noqa: E402
|
||||
|
||||
config = get_config()
|
||||
# Defensive: confirm the env var actually landed in the parsed config.
|
||||
# If pydantic-settings ever changes how it deserialises dict[str, str]
|
||||
# from env, the LAN auth would silently break (server would 401 every
|
||||
# phone scan). Logging the mismatch makes the failure mode obvious in
|
||||
# adb logcat.
|
||||
if api_key and config.auth.api_keys.get("android") != api_key:
|
||||
logger.error(
|
||||
"LedGrab Android: API key did NOT land in config — LAN auth will "
|
||||
"reject all requests. Check pydantic-settings dict parsing for "
|
||||
"LEDGRAB_AUTH__API_KEYS."
|
||||
)
|
||||
|
||||
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
|
||||
|
||||
uv_config = uvicorn.Config(
|
||||
"ledgrab.main:app",
|
||||
@@ -62,6 +93,9 @@ def start_server(data_dir: str, port: int = 8080) -> 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")
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
import threading
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -38,28 +39,59 @@ _SERVER_DIR = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _schedule_restart() -> None:
|
||||
"""Spawn a restart script after a short delay so the HTTP response completes."""
|
||||
"""Spawn a restart script after a short delay so the HTTP response completes.
|
||||
|
||||
def _restart():
|
||||
stdout/stderr of the spawned script are redirected to ``<server>/restart.log``
|
||||
so a silent failure (PowerShell not on PATH, restart.ps1 erroring, etc.)
|
||||
leaves evidence on disk instead of vanishing into a detached child.
|
||||
"""
|
||||
|
||||
def _restart() -> None:
|
||||
import time
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
# Annotated as ``dict[str, Any]`` because the value union spans
|
||||
# int flags (Windows ``creationflags``) and bool (POSIX
|
||||
# ``start_new_session``); a narrower union confuses ``**`` unpacking.
|
||||
popen_kwargs: dict[str, Any]
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(
|
||||
[
|
||||
"powershell",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(_SERVER_DIR / "restart.ps1"),
|
||||
],
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
script = _SERVER_DIR / "restart.ps1"
|
||||
cmd = ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(script)]
|
||||
popen_kwargs = {
|
||||
"creationflags": (
|
||||
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
),
|
||||
}
|
||||
else:
|
||||
subprocess.Popen(
|
||||
["bash", str(_SERVER_DIR / "restart.sh")],
|
||||
start_new_session=True,
|
||||
)
|
||||
script = _SERVER_DIR / "restart.sh"
|
||||
cmd = ["bash", str(script)]
|
||||
popen_kwargs = {"start_new_session": True}
|
||||
|
||||
if not script.is_file():
|
||||
logger.error("Restart script missing: %s", script)
|
||||
return
|
||||
|
||||
log_path = _SERVER_DIR / "restart.log"
|
||||
try:
|
||||
# Open in append mode so multiple restarts accumulate; the child
|
||||
# owns its own duped handle, so closing here in the parent is safe.
|
||||
with open(log_path, "ab") as log_file:
|
||||
log_file.write(
|
||||
f"\n--- restart spawned at {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode()
|
||||
)
|
||||
log_file.flush()
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
**popen_kwargs,
|
||||
)
|
||||
logger.info("Restart script launched: %s (PID %s, log %s)", cmd[0], proc.pid, log_path)
|
||||
except OSError as e:
|
||||
logger.error("Failed to launch restart script %s: %s", script, e, exc_info=True)
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error launching restart script: %s", e, exc_info=True)
|
||||
|
||||
threading.Thread(target=_restart, daemon=True).start()
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -84,6 +84,21 @@ class PlatformDetector:
|
||||
]
|
||||
user32.DefWindowProcW.restype = ctypes.c_ssize_t
|
||||
|
||||
# Pin the MSG pointer type so byref(msg) matches the prototype
|
||||
# (Python 3.13 ctypes rejects mismatched POINTER(MSG) caches).
|
||||
LPMSG = ctypes.POINTER(ctypes.wintypes.MSG)
|
||||
user32.GetMessageW.argtypes = [
|
||||
LPMSG,
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.c_uint,
|
||||
ctypes.c_uint,
|
||||
]
|
||||
user32.GetMessageW.restype = ctypes.c_int
|
||||
user32.TranslateMessage.argtypes = [LPMSG]
|
||||
user32.TranslateMessage.restype = ctypes.wintypes.BOOL
|
||||
user32.DispatchMessageW.argtypes = [LPMSG]
|
||||
user32.DispatchMessageW.restype = ctypes.c_ssize_t
|
||||
|
||||
def wnd_proc(hwnd, msg, wparam, lparam):
|
||||
if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE:
|
||||
try:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Auto-backup engine — periodic SQLite snapshot backups."""
|
||||
"""Auto-backup engine — periodic SQLite + assets snapshot backups."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import Iterable, List
|
||||
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.utils import get_logger
|
||||
@@ -20,19 +22,35 @@ DEFAULT_SETTINGS = {
|
||||
# Skip the immediate-on-start backup if a recent backup exists within this window.
|
||||
_STARTUP_BACKUP_COOLDOWN = timedelta(minutes=5)
|
||||
|
||||
_BACKUP_EXT = ".db"
|
||||
# Current write format. ``.db`` is still recognised on read so backups taken
|
||||
# by older versions remain listable, restorable, and prunable.
|
||||
_BACKUP_EXT = ".zip"
|
||||
_RECOGNISED_EXTS: tuple[str, ...] = (".zip", ".db")
|
||||
|
||||
# Soft warning threshold — large backups indicate an unbounded assets dir or
|
||||
# bloated DB. We don't refuse to write (user data is theirs), but log loudly
|
||||
# so the operator can investigate before disk fills up over many intervals.
|
||||
_BACKUP_SIZE_WARN_BYTES = 500 * 1024 * 1024 # 500 MB
|
||||
|
||||
|
||||
class AutoBackupEngine:
|
||||
"""Creates periodic SQLite snapshot backups of the database."""
|
||||
"""Creates periodic backups of the database and asset files.
|
||||
|
||||
Each backup is a ZIP archive containing ``ledgrab.db`` plus every file
|
||||
from ``assets_dir`` under ``assets/`` — matching the format produced by
|
||||
the manual ``GET /api/v1/system/backup`` download. The restore endpoint
|
||||
accepts either ``.zip`` or ``.db`` interchangeably.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backup_dir: Path,
|
||||
db: Database,
|
||||
assets_dir: Path | None = None,
|
||||
):
|
||||
self._backup_dir = Path(backup_dir)
|
||||
self._db = db
|
||||
self._assets_dir = Path(assets_dir) if assets_dir else None
|
||||
self._task: asyncio.Task | None = None
|
||||
self._last_backup_time: datetime | None = None
|
||||
|
||||
@@ -82,9 +100,14 @@ class AutoBackupEngine:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
|
||||
def _iter_backup_files(self) -> Iterable[Path]:
|
||||
"""Yield every backup file (both legacy ``.db`` and current ``.zip``)."""
|
||||
for ext in _RECOGNISED_EXTS:
|
||||
yield from self._backup_dir.glob(f"*{ext}")
|
||||
|
||||
def _most_recent_backup_age(self) -> timedelta | None:
|
||||
"""Return the age of the newest backup file, or None if no backups exist."""
|
||||
files = list(self._backup_dir.glob(f"*{_BACKUP_EXT}"))
|
||||
files = list(self._iter_backup_files())
|
||||
if not files:
|
||||
return None
|
||||
newest = max(files, key=lambda p: p.stat().st_mtime)
|
||||
@@ -124,15 +147,72 @@ class AutoBackupEngine:
|
||||
timestamp = now.strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-backup-{timestamp}{_BACKUP_EXT}"
|
||||
file_path = self._backup_dir / filename
|
||||
# Stage the ZIP at <name>.partial then os.replace into place once it's
|
||||
# fully written. A crash mid-write leaves a .partial file (cleaned up
|
||||
# on the next backup) but never a half-written backup that would fool
|
||||
# ``_most_recent_backup_age`` / ``_prune_old_backups`` into trusting
|
||||
# corrupt data.
|
||||
partial_path = file_path.with_suffix(file_path.suffix + ".partial")
|
||||
|
||||
self._db.backup_to(file_path)
|
||||
# SQLite backup API → temp .db so we get a consistent snapshot
|
||||
# without holding the DB lock for the ZIP write.
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
tmp_path = Path(tmp.name)
|
||||
tmp.close()
|
||||
asset_count = 0
|
||||
try:
|
||||
self._db.backup_to(tmp_path)
|
||||
with zipfile.ZipFile(partial_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(tmp_path, "ledgrab.db")
|
||||
if self._assets_dir and self._assets_dir.is_dir():
|
||||
for asset_file in self._assets_dir.iterdir():
|
||||
# Skip symlinks: ``is_file()`` follows them and we
|
||||
# don't want to silently slurp a symlink target that
|
||||
# lives outside the assets dir into every backup.
|
||||
if asset_file.is_symlink():
|
||||
continue
|
||||
if asset_file.is_file():
|
||||
zf.write(asset_file, f"assets/{asset_file.name}")
|
||||
asset_count += 1
|
||||
os.replace(partial_path, file_path)
|
||||
except Exception:
|
||||
# Roll back the staged partial so it doesn't accumulate; the
|
||||
# finally block still removes the SQLite temp file. Re-raise so
|
||||
# the caller (``_backup_loop`` / ``trigger_backup``) sees + logs
|
||||
# the failure instead of silently emitting a missing backup.
|
||||
partial_path.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
# Best-effort sweep of any older orphan .partial files left by a
|
||||
# crash on a previous run.
|
||||
for stale in self._backup_dir.glob("*.partial"):
|
||||
try:
|
||||
stale.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
size_bytes = file_path.stat().st_size
|
||||
self._last_backup_time = now
|
||||
logger.info(f"Backup created: {filename}")
|
||||
logger.info(
|
||||
"Backup created: %s (%d asset files, %.1f MB)",
|
||||
filename,
|
||||
asset_count,
|
||||
size_bytes / (1024 * 1024),
|
||||
)
|
||||
if size_bytes > _BACKUP_SIZE_WARN_BYTES:
|
||||
logger.warning(
|
||||
"Backup %s is %.1f MB — exceeds %d MB warning threshold; "
|
||||
"consider pruning the assets directory or lowering max_backups",
|
||||
filename,
|
||||
size_bytes / (1024 * 1024),
|
||||
_BACKUP_SIZE_WARN_BYTES // (1024 * 1024),
|
||||
)
|
||||
|
||||
def _prune_old_backups(self) -> None:
|
||||
max_backups = self._settings["max_backups"]
|
||||
files = sorted(self._backup_dir.glob(f"*{_BACKUP_EXT}"), key=lambda p: p.stat().st_mtime)
|
||||
files = sorted(self._iter_backup_files(), key=lambda p: p.stat().st_mtime)
|
||||
excess = len(files) - max_backups
|
||||
if excess > 0:
|
||||
for f in files[:excess]:
|
||||
@@ -179,9 +259,7 @@ class AutoBackupEngine:
|
||||
|
||||
def list_backups(self) -> List[dict]:
|
||||
backups = []
|
||||
for f in sorted(
|
||||
self._backup_dir.glob(f"*{_BACKUP_EXT}"), key=lambda p: p.stat().st_mtime, reverse=True
|
||||
):
|
||||
for f in sorted(self._iter_backup_files(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
stat = f.stat()
|
||||
backups.append(
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
@@ -283,6 +284,7 @@ async def lifespan(app: FastAPI):
|
||||
auto_backup_engine = AutoBackupEngine(
|
||||
backup_dir=_data_dir / "backups",
|
||||
db=db,
|
||||
assets_dir=Path(config.assets.assets_dir),
|
||||
)
|
||||
|
||||
# Create update service (checks for new releases)
|
||||
@@ -576,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
|
||||
@@ -644,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;
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 35 KiB |
@@ -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';
|
||||
|
||||
@@ -244,6 +244,30 @@ import {
|
||||
initDonationBanner, dismissDonation, snoozeDonation, renderAboutPanel, setProjectUrls,
|
||||
} from './features/donation.ts';
|
||||
|
||||
// ─── Out-of-band API key delivery (URL fragment) ───
|
||||
|
||||
/**
|
||||
* Parse the URL fragment for an API key delivered out-of-band.
|
||||
* Supported forms:
|
||||
* #k=<token>
|
||||
* #key=<token>
|
||||
* #/some-route#k=<token> (key wins; route still applies)
|
||||
*
|
||||
* Returns null when no key is present or the value looks invalid
|
||||
* (whitespace / suspicious characters). Tokens are constrained to a
|
||||
* conservative URL-safe alphabet to avoid accepting arbitrary input
|
||||
* — the Android launcher uses 64-char hex.
|
||||
*/
|
||||
function readApiKeyFromFragment(): string | null {
|
||||
const hash = location.hash;
|
||||
if (!hash) return null;
|
||||
// Strip leading '#' once; search inside for either ?k= or &k= or
|
||||
// a leading k=, plus the `key=` long form.
|
||||
const body = hash.replace(/^#/, '');
|
||||
const match = body.match(/(?:^|[?&#])(?:k|key)=([A-Za-z0-9_-]{16,512})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||
|
||||
Object.assign(window, {
|
||||
@@ -601,6 +625,8 @@ Object.assign(window, {
|
||||
graphZoomIn,
|
||||
graphZoomOut,
|
||||
graphRelayout,
|
||||
graphShowIssues,
|
||||
graphExportTopology,
|
||||
graphToggleFullscreen,
|
||||
graphAddEntity,
|
||||
toggleToolbarOverflow,
|
||||
@@ -724,6 +750,21 @@ window.addEventListener('beforeunload', () => {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
// Bootstrap auth: first check the URL fragment for a key delivered
|
||||
// out-of-band (e.g. the Android TV launcher embeds the per-install
|
||||
// API key in the QR as ``#k=<token>``). Fragments are never sent in
|
||||
// HTTP requests so this is safe to log. Persist to localStorage and
|
||||
// strip the hash so a refresh doesn't keep showing it in the URL.
|
||||
const tokenFromFragment = readApiKeyFromFragment();
|
||||
if (tokenFromFragment) {
|
||||
localStorage.setItem('ledgrab_api_key', tokenFromFragment);
|
||||
try {
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
} catch {
|
||||
// Older browsers without history API support — leave the
|
||||
// fragment alone; it's already cached in localStorage.
|
||||
}
|
||||
}
|
||||
// Load API key from localStorage before anything that triggers API calls
|
||||
setApiKey(localStorage.getItem('ledgrab_api_key'));
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Typed REST client — one place for the request/parse/error-unwrap dance
|
||||
* that every feature module used to hand-roll on top of `fetchWithAuth`.
|
||||
*
|
||||
* Before this module, ~25 feature files repeated the same shape:
|
||||
*
|
||||
* ```ts
|
||||
* const resp = await fetchWithAuth(url, { method, body: JSON.stringify(p) });
|
||||
* if (!resp.ok) {
|
||||
* const err = await resp.json().catch(() => ({}));
|
||||
* throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
* }
|
||||
* const data = await resp.json();
|
||||
* ```
|
||||
*
|
||||
* `apiGet` / `apiPost` / `apiPut` / `apiDelete` collapse that to a single
|
||||
* call that returns the parsed body (typed via the caller's `<T>`) and
|
||||
* throws an {@link ApiError} carrying the server's `detail` on failure.
|
||||
*
|
||||
* Auth headers, the 401 → re-login flow, timeouts, the 5xx/network retry
|
||||
* loop, and the offline-toast are all still owned by `fetchWithAuth`; this
|
||||
* is a thin typed layer on top, not a replacement.
|
||||
*
|
||||
* Audit finding M7.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, ApiError } from './api.ts';
|
||||
|
||||
export interface ApiRequestOpts {
|
||||
/**
|
||||
* Message for the thrown {@link ApiError} when the server returns a
|
||||
* non-2xx status *and* provides no usable `detail`. Defaults to
|
||||
* `HTTP <status>`. Pass a localised string (e.g. `t('foo.error.save')`)
|
||||
* to preserve the bespoke per-feature messages.
|
||||
*/
|
||||
errorMessage?: string;
|
||||
/** Abort signal forwarded to `fetchWithAuth`. */
|
||||
signal?: AbortSignal;
|
||||
/** Per-request timeout in ms (default 10 000, owned by `fetchWithAuth`). */
|
||||
timeout?: number;
|
||||
/** Disable the 5xx/network auto-retry loop (default: enabled). */
|
||||
retry?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a `Response` into a parsed body or throw {@link ApiError}.
|
||||
*
|
||||
* `detail` handling mirrors — and slightly hardens — the old hand-rolled
|
||||
* pattern: a string `detail` is used verbatim; FastAPI validation errors
|
||||
* (an array of `{msg, ...}`) are joined instead of stringifying to
|
||||
* `[object Object]`; otherwise we fall back to `errorMessage` then
|
||||
* `HTTP <status>`.
|
||||
*/
|
||||
async function unwrap<T>(resp: Response, opts?: ApiRequestOpts): Promise<T> {
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({} as Record<string, unknown>));
|
||||
const detail = (body as { detail?: unknown }).detail;
|
||||
let message: string;
|
||||
if (typeof detail === 'string' && detail) {
|
||||
message = detail;
|
||||
} else if (Array.isArray(detail) && detail.length > 0) {
|
||||
message = detail
|
||||
.map((d) => (d && typeof d === 'object' && 'msg' in d ? String((d as { msg: unknown }).msg) : String(d)))
|
||||
.join('; ');
|
||||
} else {
|
||||
message = opts?.errorMessage || `HTTP ${resp.status}`;
|
||||
}
|
||||
throw new ApiError(resp.status, message);
|
||||
}
|
||||
// 204 No Content (and other empty bodies) — nothing to parse.
|
||||
if (resp.status === 204) return undefined as T;
|
||||
const text = await resp.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
function buildOpts(method: string, body: unknown, opts?: ApiRequestOpts): RequestInit & { retry?: boolean; timeout?: number } {
|
||||
const init: RequestInit & { retry?: boolean; timeout?: number } = { method };
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
if (opts?.signal) init.signal = opts.signal;
|
||||
if (opts?.timeout !== undefined) init.timeout = opts.timeout;
|
||||
if (opts?.retry !== undefined) init.retry = opts.retry;
|
||||
return init;
|
||||
}
|
||||
|
||||
/** `GET <path>` → parsed JSON body of type `T`. */
|
||||
export async function apiGet<T>(path: string, opts?: ApiRequestOpts): Promise<T> {
|
||||
const resp = await fetchWithAuth(path, buildOpts('GET', undefined, opts));
|
||||
return unwrap<T>(resp, opts);
|
||||
}
|
||||
|
||||
/** `POST <path>` with a JSON body → parsed JSON body of type `T`. */
|
||||
export async function apiPost<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
|
||||
const resp = await fetchWithAuth(path, buildOpts('POST', body, opts));
|
||||
return unwrap<T>(resp, opts);
|
||||
}
|
||||
|
||||
/** `PUT <path>` with a JSON body → parsed JSON body of type `T`. */
|
||||
export async function apiPut<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
|
||||
const resp = await fetchWithAuth(path, buildOpts('PUT', body, opts));
|
||||
return unwrap<T>(resp, opts);
|
||||
}
|
||||
|
||||
/** `PATCH <path>` with a JSON body → parsed JSON body of type `T`. */
|
||||
export async function apiPatch<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
|
||||
const resp = await fetchWithAuth(path, buildOpts('PATCH', body, opts));
|
||||
return unwrap<T>(resp, opts);
|
||||
}
|
||||
|
||||
/** `DELETE <path>` → parsed JSON body of type `T` (often `void`/204). */
|
||||
export async function apiDelete<T = void>(path: string, opts?: ApiRequestOpts): Promise<T> {
|
||||
const resp = await fetchWithAuth(path, buildOpts('DELETE', undefined, opts));
|
||||
return unwrap<T>(resp, opts);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* Reusable data cache with fetch deduplication, invalidation, and subscribers.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, ApiError } from './api.ts';
|
||||
import { ApiError } from './api.ts';
|
||||
import { apiGet } from './api-client.ts';
|
||||
|
||||
// Server JSON is treated as `any` at the cache boundary because each
|
||||
// extractor knows the endpoint-specific shape (e.g. `json.devices`).
|
||||
@@ -66,19 +67,18 @@ export class DataCache<T = unknown> {
|
||||
|
||||
async _doFetch(): Promise<T> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(this._endpoint);
|
||||
if (!resp.ok) {
|
||||
console.error(`[DataCache] ${this._endpoint}: HTTP ${resp.status}`);
|
||||
return this._data;
|
||||
}
|
||||
const json = await resp.json();
|
||||
const json = await apiGet<any>(this._endpoint);
|
||||
this._data = this._extractData(json);
|
||||
this._fresh = true;
|
||||
this._notify();
|
||||
return this._data;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError && err.isAuth) return this._data;
|
||||
console.error(`Cache fetch ${this._endpoint}:`, err);
|
||||
if (err instanceof ApiError) {
|
||||
console.error(`[DataCache] ${this._endpoint}: HTTP ${err.status}`);
|
||||
} else {
|
||||
console.error(`Cache fetch ${this._endpoint}:`, err);
|
||||
}
|
||||
return this._data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Command Palette — global search & navigation (Ctrl+K / Cmd+K).
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from './api.ts';
|
||||
import { escapeHtml } from './api.ts';
|
||||
import { apiGet, apiPost } from './api-client.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { navigateToCard } from './navigation.ts';
|
||||
import {
|
||||
@@ -73,18 +74,18 @@ function _buildItems(results: any[], states: any = {}) {
|
||||
action: async () => {
|
||||
const isRunning = actionItem._running;
|
||||
const endpoint = isRunning ? 'stop' : 'start';
|
||||
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/${endpoint}`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
try {
|
||||
await apiPost(`/output-targets/${tgt.id}/${endpoint}`, undefined, {
|
||||
errorMessage: t(`target.error.${endpoint}_failed`),
|
||||
});
|
||||
showToast(t(isRunning ? 'device.stopped' : 'device.started'), 'success');
|
||||
actionItem._running = !isRunning;
|
||||
actionItem.detail = !isRunning ? t('search.action.stop') : t('search.action.start');
|
||||
actionItem.icon = !isRunning ? '■' : '▶';
|
||||
_render();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
const d = err.detail || err.message || '';
|
||||
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
|
||||
showToast(ds || t(`target.error.${endpoint}_failed`), 'error');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message || t(`target.error.${endpoint}_failed`), 'error');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -108,17 +109,17 @@ function _buildItems(results: any[], states: any = {}) {
|
||||
action: async () => {
|
||||
const isEnabled = autoItem._enabled;
|
||||
const endpoint = isEnabled ? 'disable' : 'enable';
|
||||
const resp = await fetchWithAuth(`/automations/${a.id}/${endpoint}`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
try {
|
||||
await apiPost(`/automations/${a.id}/${endpoint}`, undefined, {
|
||||
errorMessage: t('search.action.' + endpoint) + ' failed',
|
||||
});
|
||||
showToast(t('search.action.' + endpoint) + ': ' + a.name, 'success');
|
||||
autoItem._enabled = !isEnabled;
|
||||
autoItem.detail = !isEnabled ? t('search.action.disable') : t('search.action.enable');
|
||||
_render();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
const d = err.detail || err.message || '';
|
||||
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
|
||||
showToast(ds || (t('search.action.' + endpoint) + ' failed'), 'error');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message || (t('search.action.' + endpoint) + ' failed'), 'error');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -170,9 +171,15 @@ function _buildItems(results: any[], states: any = {}) {
|
||||
items.push({
|
||||
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' });
|
||||
if (resp.ok) { showToast(t('scenes.activated'), 'success'); }
|
||||
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('scenes.error.activate_failed'), 'error'); }
|
||||
try {
|
||||
await apiPost(`/scene-presets/${sp.id}/activate`, undefined, {
|
||||
errorMessage: t('scenes.error.activate_failed'),
|
||||
});
|
||||
showToast(t('scenes.activated'), 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message || t('scenes.error.activate_failed'), 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -209,14 +216,12 @@ const _responseKeys = [
|
||||
|
||||
async function _fetchAllEntities() {
|
||||
const [statesData, ...results] = await Promise.all([
|
||||
fetchWithAuth('/output-targets/batch/states', { retry: false, timeout: 5000 })
|
||||
.then(r => r.ok ? r.json() : {})
|
||||
.then((data: any) => data.states || {})
|
||||
apiGet<{ states?: any }>('/output-targets/batch/states', { retry: false, timeout: 5000 })
|
||||
.then((data) => data.states || {})
|
||||
.catch(() => ({})),
|
||||
..._responseKeys.map(([ep, key]) =>
|
||||
fetchWithAuth(ep as string, { retry: false, timeout: 5000 })
|
||||
.then((r: any) => r.ok ? r.json() : {})
|
||||
.then((data: any) => data[key as string] || [])
|
||||
apiGet<any>(ep as string, { retry: false, timeout: 5000 })
|
||||
.then((data) => data[key as string] || [])
|
||||
.catch((): any[] => [])),
|
||||
]);
|
||||
return _buildItems(results, statesData);
|
||||
|
||||
@@ -3,12 +3,65 @@
|
||||
* Supports creating, changing, and detaching connections via the graph editor.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from './api.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,18 +215,19 @@ 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 {
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) return false;
|
||||
await apiPut(url, body);
|
||||
// Invalidate the relevant cache so data refreshes
|
||||
if (entry.cache) entry.cache.invalidate();
|
||||
return true;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
* attachProcessPicker(container, textarea);
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from './api.ts';
|
||||
import { escapeHtml } from './api.ts';
|
||||
import { apiGet } from './api-client.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { ICON_SEARCH } from './icons.ts';
|
||||
|
||||
@@ -241,16 +242,21 @@ class NamePalette {
|
||||
/* ─── fetch helpers ────────────────────────────────────────── */
|
||||
|
||||
async function _fetchProcesses(): Promise<string[]> {
|
||||
const resp = await fetchWithAuth('/system/processes');
|
||||
if (!resp || !resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
return data.processes || [];
|
||||
try {
|
||||
const data = await apiGet<{ processes?: string[] }>('/system/processes');
|
||||
return data.processes || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function _fetchNotificationApps(): Promise<string[]> {
|
||||
const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history');
|
||||
if (!resp || !resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
let data: { history?: any[] };
|
||||
try {
|
||||
data = await apiGet<{ history?: any[] }>('/color-strip-sources/os-notifications/history');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const history: any[] = data.history || [];
|
||||
// Deduplicate app names, preserving original case of first occurrence
|
||||
const seen = new Map<string, string>();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* Tags are stored lowercase, trimmed, deduplicated.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from './api.ts';
|
||||
import { apiGet } from './api-client.ts';
|
||||
|
||||
let _allTagsCache: string[] | null = null;
|
||||
let _allTagsFetchPromise: Promise<string[]> | null = null;
|
||||
@@ -22,8 +22,7 @@ let _allTagsFetchPromise: Promise<string[]> | null = null;
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
if (_allTagsCache) return _allTagsCache;
|
||||
if (_allTagsFetchPromise) return _allTagsFetchPromise;
|
||||
_allTagsFetchPromise = fetchWithAuth('/tags')
|
||||
.then(r => r.json())
|
||||
_allTagsFetchPromise = apiGet<{ tags?: string[] }>('/tags')
|
||||
.then(data => {
|
||||
_allTagsCache = data.tags || [];
|
||||
_allTagsFetchPromise = null;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* The canvas shows monitor rectangles that can be repositioned for visual clarity.
|
||||
*/
|
||||
|
||||
import { API_BASE, fetchWithAuth } from '../core/api.ts';
|
||||
import { API_BASE } from '../core/api.ts';
|
||||
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||
import { colorStripSourcesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
@@ -137,14 +138,14 @@ const _modal = new AdvancedCalibrationModal();
|
||||
|
||||
export async function showAdvancedCalibration(cssId: string): Promise<void> {
|
||||
try {
|
||||
const [cssSources, psResp] = await Promise.all([
|
||||
const [cssSources, psData] = await Promise.all([
|
||||
colorStripSourcesCache.fetch(),
|
||||
fetchWithAuth('/picture-sources'),
|
||||
apiGet<{ streams?: PictureSource[] }>('/picture-sources').catch((): { streams?: PictureSource[] } => ({})),
|
||||
]);
|
||||
const source = cssSources.find(s => s.id === cssId);
|
||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const calibration: Calibration = source.calibration || {} as Calibration;
|
||||
const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
|
||||
const psList = psData.streams || [];
|
||||
|
||||
_state.cssId = cssId;
|
||||
_state.sourceType = source.source_type || 'picture_advanced';
|
||||
@@ -223,22 +224,13 @@ export async function saveAdvancedCalibration(): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ source_type: _state.sourceType, calibration }),
|
||||
await apiPut(`/color-strip-sources/${cssId}`, { source_type: _state.sourceType, calibration }, {
|
||||
errorMessage: t('calibration.error.save_failed'),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
_modal.forceClose();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
const detail = err.detail || err.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('calibration.error.save_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
_modal.forceClose();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(error.message || t('calibration.error.save_failed'), 'error');
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
_cachedAudioFilterDefs,
|
||||
audioFilterDefsCache,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -158,9 +159,7 @@ export async function editAudioProcessingTemplate(templateId: string) {
|
||||
try {
|
||||
if (_cachedAudioFilterDefs.length === 0) await audioFilterDefsCache.fetch();
|
||||
|
||||
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||||
const tmpl = await response.json();
|
||||
const tmpl = await apiGet<any>(`/audio-processing-templates/${templateId}`);
|
||||
|
||||
document.getElementById('apt-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_processing.edit')}`;
|
||||
(document.getElementById('apt-id') as HTMLInputElement).value = templateId;
|
||||
@@ -212,13 +211,10 @@ export async function saveAudioProcessingTemplate() {
|
||||
};
|
||||
|
||||
try {
|
||||
const url = templateId ? `/audio-processing-templates/${templateId}` : '/audio-processing-templates';
|
||||
const method = templateId ? 'PUT' : 'POST';
|
||||
const response = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save template');
|
||||
if (templateId) {
|
||||
await apiPut(`/audio-processing-templates/${templateId}`, payload, { errorMessage: t('audio_processing.error.save_failed') });
|
||||
} else {
|
||||
await apiPost('/audio-processing-templates', payload, { errorMessage: t('audio_processing.error.save_failed') });
|
||||
}
|
||||
|
||||
showToast(templateId ? t('audio_processing.updated') : t('audio_processing.created'), 'success');
|
||||
@@ -235,9 +231,7 @@ export async function saveAudioProcessingTemplate() {
|
||||
|
||||
export async function cloneAudioProcessingTemplate(templateId: string) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-processing-templates/${templateId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load template');
|
||||
const tmpl = await resp.json();
|
||||
const tmpl = await apiGet<any>(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.load') });
|
||||
await showAudioProcessingTemplateModal(tmpl);
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
@@ -252,11 +246,7 @@ export async function deleteAudioProcessingTemplate(templateId: string) {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||
}
|
||||
await apiDelete(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.delete') });
|
||||
showToast(t('audio_processing.deleted'), 'success');
|
||||
audioProcessingTemplatesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
*/
|
||||
|
||||
import { _cachedAudioSources, _cachedAudioTemplates, _cachedAudioProcessingTemplates, audioProcessingTemplatesCache, apiKey, audioSourcesCache } from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -178,16 +179,10 @@ export async function saveAudioSource() {
|
||||
}
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/audio-sources/${id}` : '/audio-sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/audio-sources/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/audio-sources', payload);
|
||||
}
|
||||
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
|
||||
audioSourceModal.forceClose();
|
||||
@@ -203,9 +198,7 @@ export async function saveAudioSource() {
|
||||
|
||||
export async function editAudioSource(sourceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('audio_source.error.load'));
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') });
|
||||
await showAudioSourceModal(data.source_type, data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -217,12 +210,9 @@ export async function editAudioSource(sourceId: any) {
|
||||
|
||||
export async function cloneAudioSource(sourceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('audio_source.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showAudioSourceModal(data.source_type, data);
|
||||
const data = await apiGet<any>(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showAudioSourceModal(data.source_type, { ...rest, name: `${data.name} (copy)` });
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -236,11 +226,7 @@ export async function deleteAudioSource(sourceId: any) {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/audio-sources/${sourceId}`);
|
||||
showToast(t('audio_source.deleted'), 'success');
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
@@ -267,9 +253,7 @@ let _cachedDevicesByEngine = {};
|
||||
|
||||
async function _loadAudioDevices() {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/audio-devices');
|
||||
if (!resp.ok) throw new Error('fetch failed');
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ by_engine?: Record<string, any[]> }>('/audio-devices');
|
||||
_cachedDevicesByEngine = data.by_engine || {};
|
||||
} catch {
|
||||
_cachedDevicesByEngine = {};
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
import {
|
||||
calibrationTestState, EDGE_TEST_COLORS, displaysCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.ts';
|
||||
import { API_BASE, getHeaders } from '../core/api.ts';
|
||||
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||
import { colorStripSourcesCache, devicesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
@@ -92,10 +93,7 @@ async function _clearCSSTestMode() {
|
||||
const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value;
|
||||
if (!testDeviceId) return;
|
||||
try {
|
||||
await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ device_id: testDeviceId, edges: {} }),
|
||||
});
|
||||
await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges: {} });
|
||||
} catch (err) {
|
||||
console.error('Failed to clear CSS test mode:', err);
|
||||
}
|
||||
@@ -109,11 +107,8 @@ function _setOverlayBtnActive(active: any) {
|
||||
|
||||
async function _checkOverlayStatus(cssId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_setOverlayBtnActive(data.active);
|
||||
}
|
||||
const data = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`);
|
||||
_setOverlayBtnActive(!!data.active);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -121,9 +116,7 @@ export async function toggleCalibrationOverlay() {
|
||||
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
|
||||
if (!cssId) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
|
||||
if (!resp.ok) return;
|
||||
const { active } = await resp.json();
|
||||
const { active } = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`);
|
||||
if (active) {
|
||||
await stopCSSOverlay(cssId);
|
||||
_setOverlayBtnActive(false);
|
||||
@@ -143,14 +136,11 @@ export async function toggleCalibrationOverlay() {
|
||||
|
||||
export async function showCalibration(deviceId: any) {
|
||||
try {
|
||||
const [response, displays] = await Promise.all([
|
||||
fetchWithAuth(`/devices/${deviceId}`),
|
||||
const [device, displays] = await Promise.all([
|
||||
apiGet<any>(`/devices/${deviceId}`),
|
||||
displaysCache.fetch().catch((): any[] => []),
|
||||
]);
|
||||
|
||||
if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; }
|
||||
|
||||
const device = await response.json();
|
||||
const calibration = device.calibration;
|
||||
|
||||
const preview = document.querySelector('.calibration-preview') as HTMLElement;
|
||||
@@ -843,17 +833,9 @@ export async function toggleTestEdge(edge: any) {
|
||||
updateCalibrationPreview();
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ device_id: testDeviceId, edges }),
|
||||
await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges }, {
|
||||
errorMessage: t('calibration.error.test_toggle_failed'),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to toggle CSS test edge:', err);
|
||||
@@ -875,17 +857,9 @@ export async function toggleTestEdge(edge: any) {
|
||||
updateCalibrationPreview();
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/devices/${deviceId}/calibration/test`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ edges })
|
||||
await apiPut(`/devices/${deviceId}/calibration/test`, { edges }, {
|
||||
errorMessage: t('calibration.error.test_toggle_failed'),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to toggle test edge:', err);
|
||||
@@ -965,34 +939,23 @@ export async function saveCalibration() {
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (cssMode) {
|
||||
const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture';
|
||||
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ source_type: cssSourceType, calibration, led_count: declaredLedCount }),
|
||||
await apiPut(`/color-strip-sources/${cssId}`, { source_type: cssSourceType, calibration, led_count: declaredLedCount }, {
|
||||
errorMessage: t('calibration.error.save_failed'),
|
||||
});
|
||||
} else {
|
||||
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(calibration),
|
||||
await apiPut(`/devices/${deviceId}/calibration`, calibration, {
|
||||
errorMessage: t('calibration.error.save_failed'),
|
||||
});
|
||||
}
|
||||
if (response.ok) {
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
if (cssMode) colorStripSourcesCache.invalidate();
|
||||
calibModal.forceClose();
|
||||
if (cssMode) {
|
||||
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||
} else {
|
||||
window.loadDevices();
|
||||
}
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
if (cssMode) colorStripSourcesCache.invalidate();
|
||||
calibModal.forceClose();
|
||||
if (cssMode) {
|
||||
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('calibration.error.save_failed');
|
||||
error.style.display = 'block';
|
||||
window.loadDevices();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* Surface keys are free-form strings — anything calling `setCardMode` is
|
||||
* implicitly registering that key. Defaults are returned for unknown keys.
|
||||
*/
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
|
||||
const LS_KEY = 'card_modes_v1';
|
||||
@@ -131,10 +131,7 @@ function _scheduleServerPush(): void {
|
||||
|
||||
async function _pushToServer(prefs: CardModePrefsV1): Promise<void> {
|
||||
try {
|
||||
await fetchWithAuth('/preferences/card-modes', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(prefs),
|
||||
});
|
||||
await apiPut('/preferences/card-modes', prefs);
|
||||
} catch (e) {
|
||||
console.warn('card-modes server PUT failed', e);
|
||||
}
|
||||
@@ -160,9 +157,7 @@ export function hydrateCardModesFromCache(): CardModePrefsV1 {
|
||||
export async function syncCardModesFromServer(): Promise<void> {
|
||||
if (_serverSyncedOnce) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/preferences/card-modes');
|
||||
if (!resp || !resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>('/preferences/card-modes');
|
||||
if (data && typeof data === 'object' && (data as Record<string, unknown>).version) {
|
||||
_current = _normalise(data);
|
||||
_persistLocal();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Extracted from color-strips.ts to reduce file size.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from '../../core/api.ts';
|
||||
import { apiPost, apiPut, apiDelete } from '../../core/api-client.ts';
|
||||
import { gradientsCache, GradientEntity } from '../../core/state.ts';
|
||||
import { t } from '../../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../../core/ui.ts';
|
||||
@@ -98,11 +98,7 @@ export async function promptAndSaveGradientPreset() {
|
||||
color: s.color,
|
||||
}));
|
||||
try {
|
||||
await fetchWithAuth('/gradients', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), stops }),
|
||||
});
|
||||
await apiPost('/gradients', { name: name.trim(), stops });
|
||||
await gradientsCache.fetch({ force: true });
|
||||
showToast(t('color_strip.gradient.preset.saved'), 'success');
|
||||
} catch (e: any) {
|
||||
@@ -112,7 +108,7 @@ export async function promptAndSaveGradientPreset() {
|
||||
|
||||
export async function deleteAndRefreshGradientPreset(gradientId: any) {
|
||||
try {
|
||||
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
|
||||
await apiDelete(`/gradients/${gradientId}`);
|
||||
await gradientsCache.fetch({ force: true });
|
||||
showToast(t('color_strip.gradient.preset.deleted'), 'success');
|
||||
} catch (e: any) {
|
||||
@@ -221,12 +217,10 @@ export async function saveGradientEntity() {
|
||||
const payload: any = { name, stops, description, tags };
|
||||
|
||||
try {
|
||||
const url = id ? `/gradients/${id}` : '/gradients';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||
if (!res!.ok) {
|
||||
const err = await res!.json();
|
||||
throw new Error(err.detail || 'Failed to save gradient');
|
||||
if (id) {
|
||||
await apiPut(`/gradients/${id}`, payload, { errorMessage: t('gradient.error.save_failed') });
|
||||
} else {
|
||||
await apiPost('/gradients', payload, { errorMessage: t('gradient.error.save_failed') });
|
||||
}
|
||||
|
||||
showToast(id ? t('gradient.updated') : t('gradient.created'), 'success');
|
||||
@@ -256,7 +250,7 @@ export async function deleteGradient(gradientId: string) {
|
||||
const ok = await showConfirm(t('gradient.confirm_delete', { name: g.name }));
|
||||
if (!ok) return;
|
||||
try {
|
||||
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
|
||||
await apiDelete(`/gradients/${gradientId}`, { errorMessage: t('gradient.error.delete_failed') });
|
||||
gradientsCache.invalidate();
|
||||
showToast(t('gradient.deleted'), 'success');
|
||||
if (window.loadPictureSources) await window.loadPictureSources();
|
||||
|
||||
@@ -180,6 +180,8 @@ class CSSEditorModal extends Modal {
|
||||
notification_default_color: getNotificationDefaultColorSnapshot(),
|
||||
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
|
||||
notification_sound: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value,
|
||||
notification_volume: getNotificationVolumeSnapshot(),
|
||||
notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()),
|
||||
clock_id: (document.getElementById('css-editor-clock') as HTMLInputElement).value,
|
||||
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Extracted from color-strips.ts to reduce file size.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../../core/api.ts';
|
||||
import { escapeHtml } from '../../core/api.ts';
|
||||
import { apiGet, apiPost } from '../../core/api-client.ts';
|
||||
import { t } from '../../core/i18n.ts';
|
||||
import { showToast } from '../../core/ui.ts';
|
||||
import {
|
||||
@@ -232,15 +233,14 @@ function _overridesRenderList() {
|
||||
|
||||
// Wire EntitySelects for sound dropdowns
|
||||
list.querySelectorAll<HTMLSelectElement>('.notif-override-sound').forEach(sel => {
|
||||
const items = _getSoundAssetItems();
|
||||
if (items.length > 0) {
|
||||
const es = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => _getSoundAssetItems(),
|
||||
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
|
||||
});
|
||||
_overrideEntitySelects.push(es);
|
||||
}
|
||||
const es = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => _getSoundAssetItems(),
|
||||
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
|
||||
allowNone: true,
|
||||
noneLabel: t('color_strip.notification.sound.none'),
|
||||
});
|
||||
_overrideEntitySelects.push(es);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -299,34 +299,30 @@ export function ensureNotifSoundEntitySelect() {
|
||||
if (!sel) return;
|
||||
_populateSoundOptions(sel);
|
||||
if (_notifSoundEntitySelect) _notifSoundEntitySelect.destroy();
|
||||
const items = _getSoundAssetItems();
|
||||
if (items.length > 0) {
|
||||
_notifSoundEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => _getSoundAssetItems(),
|
||||
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
|
||||
});
|
||||
}
|
||||
_notifSoundEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => _getSoundAssetItems(),
|
||||
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
|
||||
allowNone: true,
|
||||
noneLabel: t('color_strip.notification.sound.none'),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Test notification ────────────────────────────────────────── */
|
||||
|
||||
export async function testNotification(sourceId: string) {
|
||||
try {
|
||||
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showToast(err.detail || t('color_strip.notification.test.error'), 'error');
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.streams_notified > 0) {
|
||||
const data = await apiPost<{ streams_notified?: number }>(
|
||||
`/color-strip-sources/${sourceId}/notify`, undefined,
|
||||
{ errorMessage: t('color_strip.notification.test.error') },
|
||||
);
|
||||
if ((data.streams_notified ?? 0) > 0) {
|
||||
showToast(t('color_strip.notification.test.ok'), 'success');
|
||||
} else {
|
||||
showToast(t('color_strip.notification.test.no_streams'), 'warning');
|
||||
}
|
||||
} catch {
|
||||
showToast(t('color_strip.notification.test.error'), 'error');
|
||||
} catch (e: any) {
|
||||
showToast(e?.message || t('color_strip.notification.test.error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,9 +351,7 @@ async function _loadNotificationHistory() {
|
||||
if (!list) return;
|
||||
|
||||
try {
|
||||
const resp = (await fetchWithAuth('/color-strip-sources/os-notifications/history'))!;
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>('/color-strip-sources/os-notifications/history');
|
||||
|
||||
if (!data.available) {
|
||||
list.innerHTML = '';
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* not a closed enum. New cards can be added in v1.1+ (audio meters, alerts,
|
||||
* preview strips, etc.) without a schema bump or migration.
|
||||
*/
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||
|
||||
const LS_KEY = 'dashboard_layout_v1';
|
||||
const SCHEMA_VERSION = 1;
|
||||
@@ -397,9 +397,7 @@ export function hydrateDashboardLayoutFromCache(): DashboardLayoutV1 {
|
||||
export async function syncDashboardLayoutFromServer(): Promise<void> {
|
||||
if (_serverSyncedOnce) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/preferences/dashboard-layout');
|
||||
if (!resp || !resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>('/preferences/dashboard-layout');
|
||||
if (data && typeof data === 'object' && data.version) {
|
||||
const merged = _mergeWithDefaults(data);
|
||||
_current = merged;
|
||||
@@ -431,10 +429,7 @@ export function saveDashboardLayout(next: DashboardLayoutV1): void {
|
||||
|
||||
async function _pushToServer(layout: DashboardLayoutV1): Promise<void> {
|
||||
try {
|
||||
await fetchWithAuth('/preferences/dashboard-layout', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(layout),
|
||||
});
|
||||
await apiPut('/preferences/dashboard-layout', layout);
|
||||
} catch (e) {
|
||||
console.warn('dashboard layout PUT failed', e);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
import {
|
||||
_discoveryScanRunning, set_discoveryScanRunning,
|
||||
_discoveryCache, set_discoveryCache,
|
||||
csptCache,
|
||||
csptCache, mqttSourcesCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost } from '../core/api-client.ts';
|
||||
import { devicesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, desktopFocus } from '../core/ui.ts';
|
||||
@@ -17,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'); }
|
||||
@@ -43,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',
|
||||
@@ -71,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');
|
||||
@@ -103,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() {
|
||||
@@ -296,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
|
||||
@@ -313,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');
|
||||
@@ -919,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;
|
||||
@@ -1036,20 +1080,11 @@ export async function scanForDevices(forceType?: any) {
|
||||
|
||||
try {
|
||||
const scanTimeout = scanType === 'ble' ? 8 : 3;
|
||||
const response = await fetchWithAuth(`/devices/discover?timeout=${scanTimeout}&device_type=${encodeURIComponent(scanType)}`);
|
||||
const data = await apiGet<{ devices?: any[] }>(`/devices/discover?timeout=${scanTimeout}&device_type=${encodeURIComponent(scanType)}`);
|
||||
|
||||
loading.style.display = 'none';
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
|
||||
if (!response.ok) {
|
||||
if (!isSerialDevice(scanType)) {
|
||||
empty.style.display = 'block';
|
||||
(empty.querySelector('small') as HTMLElement).textContent = t('device.scan.error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
_discoveryCache[scanType] = data.devices || [];
|
||||
|
||||
// Only render if the user is still on this type
|
||||
@@ -1225,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';
|
||||
@@ -1267,36 +1306,25 @@ export async function handleAddDevice(event: any) {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetchWithAuth('/devices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
// result is logged by the API layer; no console.log here.
|
||||
showToast(t('device_discovery.added'), 'success');
|
||||
devicesCache.invalidate();
|
||||
addDeviceModal.forceClose();
|
||||
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
||||
if (!localStorage.getItem('deviceTutorialSeen')) {
|
||||
localStorage.setItem('deviceTutorialSeen', '1');
|
||||
setTimeout(() => {
|
||||
if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error('Failed to add device:', errorData);
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
error.textContent = detailStr || t('device_discovery.error.add_failed');
|
||||
error.style.display = 'block';
|
||||
await apiPost('/devices', body, { errorMessage: t('device_discovery.error.add_failed') });
|
||||
showToast(t('device_discovery.added'), 'success');
|
||||
devicesCache.invalidate();
|
||||
addDeviceModal.forceClose();
|
||||
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
||||
if (!localStorage.getItem('deviceTutorialSeen')) {
|
||||
localStorage.setItem('deviceTutorialSeen', '1');
|
||||
setTimeout(() => {
|
||||
if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial();
|
||||
}, 300);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to add device:', err);
|
||||
showToast(err.message || t('device_discovery.error.add_failed'), 'error');
|
||||
// Surface the message inline (HTTP errors carry the server detail,
|
||||
// array-detail is joined by the api-client; network errors fall back
|
||||
// to the localised default).
|
||||
error.textContent = err.message || t('device_discovery.error.add_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1315,17 +1343,15 @@ export async function _fetchOpenrgbZones(baseUrl: any, containerId: any, preChec
|
||||
container.innerHTML = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
container.innerHTML = `<span class="zone-error">${err.detail || t('device.openrgb.zone.error')}</span>`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ zones?: any[] }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`, {
|
||||
errorMessage: t('device.openrgb.zone.error'),
|
||||
});
|
||||
_renderZoneCheckboxes(container, data.zones, preChecked);
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`;
|
||||
// HTTP errors carry the server detail in err.message; fall back to
|
||||
// the localised generic on network errors.
|
||||
container.innerHTML = `<span class="zone-error">${escapeHtml(err.message || t('device.openrgb.zone.error'))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1733,9 +1759,7 @@ function _showGameSenseFields(show: boolean) {
|
||||
|
||||
export async function cloneDevice(deviceId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load device');
|
||||
const device = await resp.json();
|
||||
const device = await apiGet<any>(`/devices/${deviceId}`, { errorMessage: t('device.error.load_failed') });
|
||||
showAddDevice(device.device_type || 'wled', device);
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
|
||||
@@ -4,26 +4,28 @@
|
||||
|
||||
import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
csptCache,
|
||||
csptCache, mqttSourcesCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
|
||||
import { API_BASE, getHeaders, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { devicesCache } from '../core/state.ts';
|
||||
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts';
|
||||
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).
|
||||
@@ -72,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'); }
|
||||
|
||||
@@ -102,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 || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -363,19 +389,13 @@ export async function turnOffDevice(deviceId: any) {
|
||||
const confirmed = await showConfirm(t('confirm.turn_off_device'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ power: false })
|
||||
await apiPut(`/devices/${deviceId}/power`, { power: false }, {
|
||||
errorMessage: t('device.error.power_off_failed'),
|
||||
});
|
||||
if (setResp.ok) {
|
||||
showToast(t('device.power.off_success'), 'success');
|
||||
} else {
|
||||
const error = await setResp.json();
|
||||
showToast(error.detail || 'Failed', 'error');
|
||||
}
|
||||
showToast(t('device.power.off_success'), 'success');
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('device.error.power_off_failed'), 'error');
|
||||
showToast(error.message || t('device.error.power_off_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,23 +403,19 @@ export async function pingDevice(deviceId: any) {
|
||||
const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null;
|
||||
if (btn) btn.classList.add('spinning');
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?';
|
||||
showToast(data.device_online
|
||||
? t('device.ping.online', { ms })
|
||||
: t('device.ping.offline'), data.device_online ? 'success' : 'error');
|
||||
// Refresh device cards to update health dot
|
||||
devicesCache.invalidate();
|
||||
await window.loadDevices();
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
showToast(err.detail || 'Ping failed', 'error');
|
||||
}
|
||||
const data = await apiPost<{ device_online?: boolean; device_latency_ms?: number }>(
|
||||
`/devices/${deviceId}/ping`, undefined, { errorMessage: t('device.ping.error') },
|
||||
);
|
||||
const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?';
|
||||
showToast(data.device_online
|
||||
? t('device.ping.online', { ms })
|
||||
: t('device.ping.offline'), data.device_online ? 'success' : 'error');
|
||||
// Refresh device cards to update health dot
|
||||
devicesCache.invalidate();
|
||||
await window.loadDevices();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('device.ping.error'), 'error');
|
||||
showToast(error.message || t('device.ping.error'), 'error');
|
||||
} finally {
|
||||
if (btn) btn.classList.remove('spinning');
|
||||
}
|
||||
@@ -414,30 +430,20 @@ export async function removeDevice(deviceId: any) {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/devices/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('device.removed'), 'success');
|
||||
devicesCache.invalidate();
|
||||
window.loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.detail || t('device.error.remove_failed'), 'error');
|
||||
}
|
||||
await apiDelete(`/devices/${deviceId}`, { errorMessage: t('device.error.remove_failed') });
|
||||
showToast(t('device.removed'), 'success');
|
||||
devicesCache.invalidate();
|
||||
window.loadDevices();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to remove device:', error);
|
||||
showToast(t('device.error.remove_failed'), 'error');
|
||||
showToast(error.message || t('device.error.remove_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function showSettings(deviceId: any) {
|
||||
try {
|
||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`);
|
||||
if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; }
|
||||
|
||||
const device = await deviceResponse.json();
|
||||
const device = await apiGet<any>(`/devices/${deviceId}`, { errorMessage: t('device.error.settings_load_failed') });
|
||||
const isAdalight = isSerialDevice(device.device_type);
|
||||
const caps = device.capabilities || [];
|
||||
|
||||
@@ -458,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');
|
||||
@@ -477,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');
|
||||
@@ -824,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();
|
||||
@@ -838,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() {
|
||||
@@ -927,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 !== '');
|
||||
@@ -934,18 +953,7 @@ export async function saveDeviceSettings() {
|
||||
}
|
||||
const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '';
|
||||
body.default_css_processing_template_id = csptId;
|
||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!deviceResponse.ok) {
|
||||
const errorData = await deviceResponse.json();
|
||||
const detail = errorData.detail || errorData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
settingsModal.showError(detailStr || t('device.error.update'));
|
||||
return;
|
||||
}
|
||||
await apiPut(`/devices/${deviceId}`, body, { errorMessage: t('device.error.update') });
|
||||
|
||||
showToast(t('settings.saved'), 'success');
|
||||
devicesCache.invalidate();
|
||||
@@ -978,16 +986,9 @@ export async function saveCardBrightness(deviceId: any, value: any) {
|
||||
const bri = parseInt(value);
|
||||
updateDeviceBrightness(deviceId, bri);
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ brightness: bri })
|
||||
await apiPut(`/devices/${deviceId}/brightness`, { brightness: bri }, {
|
||||
errorMessage: t('device.error.brightness'),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const detail = errData.detail || errData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('device.error.brightness'), 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isAuth) return;
|
||||
showToast(err.message || t('device.error.brightness'), 'error');
|
||||
@@ -999,9 +1000,7 @@ export async function fetchDeviceBrightness(deviceId: any) {
|
||||
if (_brightnessFetchInFlight.has(deviceId)) return;
|
||||
_brightnessFetchInFlight.add(deviceId);
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>(`/devices/${deviceId}/brightness`);
|
||||
updateDeviceBrightness(deviceId, data.brightness);
|
||||
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
|
||||
if (slider) {
|
||||
@@ -1078,9 +1077,7 @@ async function _populateSettingsSerialPorts(currentUrl: any) {
|
||||
|
||||
try {
|
||||
const discoverType = settingsModal.deviceType || 'adalight';
|
||||
const resp = await fetchWithAuth(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ devices?: any[] }>(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
|
||||
const devices = data.devices || [];
|
||||
|
||||
select.innerHTML = '';
|
||||
@@ -1154,11 +1151,9 @@ export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) {
|
||||
_zoneCountInFlight.add(baseUrl);
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ zones?: Array<{ name: string; led_count: number }> }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
||||
const counts: any = {};
|
||||
for (const z of data.zones) {
|
||||
for (const z of (data.zones || [])) {
|
||||
counts[z.name.toLowerCase()] = z.led_count;
|
||||
}
|
||||
_zoneCountCache[baseUrl] = counts;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
availableEngines,
|
||||
} from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiGet, apiPost } from '../core/api-client.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import type { Display } from '../types.ts';
|
||||
|
||||
@@ -87,9 +87,7 @@ async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void>
|
||||
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/config/displays?engine_type=${engineType}`);
|
||||
if (!resp.ok) throw new Error(`${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ displays?: Display[] }>(`/config/displays?engine_type=${engineType}`);
|
||||
const displays = data.displays || [];
|
||||
|
||||
// Store in cache so selectDisplay() can look them up
|
||||
@@ -137,14 +135,10 @@ window._adbConnectFromPicker = async function () {
|
||||
|
||||
input.disabled = true;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/adb/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ address }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Connection failed');
|
||||
}
|
||||
// No errorMessage option: the catch already prefixes the toast with
|
||||
// the localised `displays.picker.adb_connect.error` label, and the
|
||||
// server's `detail` (or `HTTP <status>` fallback) becomes the suffix.
|
||||
await apiPost('/adb/connect', { address });
|
||||
showToast(t('displays.picker.adb_connect.success'), 'success');
|
||||
|
||||
// Refresh the picker with updated device list
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
gameIntegrationsCache, gameAdaptersCache,
|
||||
_cachedGameIntegrations, _cachedGameAdapters,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -48,10 +49,8 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
// ── Bulk actions ──
|
||||
|
||||
function _bulkDeleteGameIntegrations(ids: string[]) {
|
||||
return Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/game-integrations/${id}`, { method: 'DELETE' })
|
||||
)).then(results => {
|
||||
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
|
||||
return Promise.allSettled(ids.map(id => apiDelete(`/game-integrations/${id}`))).then(results => {
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||
else showToast(t('game_integration.deleted'), 'success');
|
||||
gameIntegrationsCache.invalidate();
|
||||
@@ -192,13 +191,9 @@ export async function autoSetupGameIntegration() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth(`/game-integrations/${id}/auto-setup`, { method: 'POST' });
|
||||
if (!res || !res.ok) {
|
||||
const err = await res!.json();
|
||||
showToast(err.detail || t('game_integration.auto_setup.failed'), 'error');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const data = await apiPost<{ success: boolean; file_path?: string; token_generated?: boolean; message?: string }>(
|
||||
`/game-integrations/${id}/auto-setup`, undefined, { errorMessage: t('game_integration.auto_setup.failed') },
|
||||
);
|
||||
if (data.success) {
|
||||
let msg = t('game_integration.auto_setup.success');
|
||||
if (data.file_path) msg += `\n${data.file_path}`;
|
||||
@@ -424,11 +419,8 @@ let _cachedPresets: EffectPreset[] = [];
|
||||
async function _loadPresets(): Promise<EffectPreset[]> {
|
||||
if (_cachedPresets.length > 0) return _cachedPresets;
|
||||
try {
|
||||
const res = await fetchWithAuth('/game-integrations/presets');
|
||||
if (res && res.ok) {
|
||||
const data = await res.json();
|
||||
_cachedPresets = data.presets || [];
|
||||
}
|
||||
const data = await apiGet<{ presets?: EffectPreset[] }>('/game-integrations/presets');
|
||||
_cachedPresets = data.presets || [];
|
||||
} catch { /* ignore */ }
|
||||
return _cachedPresets;
|
||||
}
|
||||
@@ -494,10 +486,8 @@ function _startEventMonitor(integrationId: string) {
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetchWithAuth(`/game-integrations/${integrationId}/events`);
|
||||
if (!res || !res.ok) return;
|
||||
const data = await res.json();
|
||||
const events: GameEventRecord[] = data.events || [];
|
||||
const data = await apiGet<{ events?: GameEventRecord[] }>(`/game-integrations/${integrationId}/events`);
|
||||
const events = data.events || [];
|
||||
if (events.length === 0) return;
|
||||
feed.innerHTML = events.slice(0, 20).map(ev => {
|
||||
const ts = new Date(ev.timestamp).toLocaleTimeString();
|
||||
@@ -535,9 +525,7 @@ export function testGameConnection() {
|
||||
_connectionTestTimer = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const res = await fetchWithAuth(`/game-integrations/${id}/status`);
|
||||
if (!res || !res.ok) return;
|
||||
const status: GameIntegrationStatus = await res.json();
|
||||
const status = await apiGet<GameIntegrationStatus>(`/game-integrations/${id}/status`);
|
||||
if (status.event_count > 0) {
|
||||
clearInterval(_connectionTestTimer!);
|
||||
_connectionTestTimer = null;
|
||||
@@ -725,12 +713,10 @@ export async function saveGameIntegration() {
|
||||
};
|
||||
|
||||
try {
|
||||
const url = id ? `/game-integrations/${id}` : '/game-integrations';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||
if (!res || !res.ok) {
|
||||
const err = await res!.json();
|
||||
throw new Error(err.detail || t('game_integration.error.save_failed'));
|
||||
if (id) {
|
||||
await apiPut(`/game-integrations/${id}`, payload, { errorMessage: t('game_integration.error.save_failed') });
|
||||
} else {
|
||||
await apiPost('/game-integrations', payload, { errorMessage: t('game_integration.error.save_failed') });
|
||||
}
|
||||
showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success');
|
||||
gameIntegrationsCache.invalidate();
|
||||
@@ -746,7 +732,7 @@ export async function deleteGameIntegration(entityId: string) {
|
||||
const ok = await showConfirm(t('game_integration.confirm_delete'));
|
||||
if (!ok) return;
|
||||
try {
|
||||
await fetchWithAuth(`/game-integrations/${entityId}`, { method: 'DELETE' });
|
||||
await apiDelete(`/game-integrations/${entityId}`, { errorMessage: t('game_integration.error.delete_failed') });
|
||||
showToast(t('game_integration.deleted'), 'success');
|
||||
gameIntegrationsCache.invalidate();
|
||||
loadGameIntegrations();
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
colorStripSourcesCache, outputTargetsCache, valueSourcesCache,
|
||||
getHAEntityFriendlyName, setHAEntityNames,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -149,9 +150,7 @@ function _getEntityItems() {
|
||||
async function _fetchHAEntities(haSourceId: string): Promise<void> {
|
||||
if (!haSourceId) { _cachedHAEntities = []; return; }
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
if (!resp.ok) { _cachedHAEntities = []; return; }
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
_cachedHAEntities = data.entities || [];
|
||||
// Mirror into the shared cache so card chips/swatches across the
|
||||
// app pick up friendly names on the next render.
|
||||
@@ -381,9 +380,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
||||
|
||||
if (isEdit) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
editData = await resp.json();
|
||||
editData = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -542,22 +539,10 @@ export async function saveHALightEditor(): Promise<void> {
|
||||
payload.target_type = 'ha_light';
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (targetId) {
|
||||
response = await fetchWithAuth(`/output-targets/${targetId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await apiPut(`/output-targets/${targetId}`, payload);
|
||||
} else {
|
||||
response = await fetchWithAuth('/output-targets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${response.status}`);
|
||||
await apiPost('/output-targets', payload);
|
||||
}
|
||||
|
||||
showToast(targetId ? t('ha_light.updated') : t('ha_light.created'), 'success');
|
||||
@@ -579,12 +564,9 @@ export async function editHALightTarget(targetId: string): Promise<void> {
|
||||
|
||||
export async function cloneHALightTarget(targetId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showHALightEditor(null, data);
|
||||
const data = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showHALightEditor(null, { ...rest, name: `${data.name} (copy)` });
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -834,8 +816,7 @@ const _haLightActions: Record<string, (id: string) => void> = {
|
||||
|
||||
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await apiPost(`/output-targets/${targetId}/${action}`);
|
||||
outputTargetsCache.invalidate();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} catch (e: any) {
|
||||
@@ -848,19 +829,13 @@ export async function turnOffHALightTarget(targetId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('confirm.turn_off_ha_light') || 'Turn off mapped lights?');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(
|
||||
`/output-targets/${targetId}/ha-light/turn-off`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (resp.ok) {
|
||||
showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success');
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showToast(err.detail || t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
|
||||
}
|
||||
await apiPost(`/output-targets/${targetId}/ha-light/turn-off`, undefined, {
|
||||
errorMessage: t('ha_light.turn_off.failed') || 'Failed to turn off lights',
|
||||
});
|
||||
showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
|
||||
showToast(e.message || t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
_cachedHASources, haSourcesCache,
|
||||
_haEntityNamesCache, setHAEntityNames,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -75,12 +76,10 @@ const haSourceModal = new HASourceModal();
|
||||
export async function fetchHAEntities(haSourceId: string): Promise<void> {
|
||||
if (!haSourceId) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
setHAEntityNames(haSourceId, data.entities || []);
|
||||
} catch {
|
||||
// Leave any existing cache entry intact.
|
||||
// Leave any existing cache entry intact (any non-2xx or network error).
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,16 +173,10 @@ export async function saveHASource(): Promise<void> {
|
||||
if (token) payload.token = token;
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/home-assistant/sources/${id}` : '/home-assistant/sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/home-assistant/sources/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/home-assistant/sources', payload);
|
||||
}
|
||||
showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success');
|
||||
haSourceModal.forceClose();
|
||||
@@ -199,9 +192,7 @@ export async function saveHASource(): Promise<void> {
|
||||
|
||||
export async function editHASource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('ha_source.error.load'));
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
|
||||
await showHASourceModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -211,12 +202,9 @@ export async function editHASource(sourceId: string): Promise<void> {
|
||||
|
||||
export async function cloneHASource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('ha_source.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showHASourceModal(data);
|
||||
const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showHASourceModal({ ...rest, name: `${data.name} (copy)` } as HomeAssistantSource);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -227,11 +215,7 @@ export async function deleteHASource(sourceId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('ha_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/home-assistant/sources/${sourceId}`);
|
||||
showToast(t('ha_source.deleted'), 'success');
|
||||
haSourcesCache.invalidate();
|
||||
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
||||
@@ -251,9 +235,7 @@ export async function testHASource(): Promise<void> {
|
||||
if (testBtn) testBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${id}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<HATestResult>(`/home-assistant/sources/${id}/test`);
|
||||
if (data.success) {
|
||||
showToast(`${t('ha_source.test.success')} | HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
|
||||
} else {
|
||||
@@ -267,6 +249,14 @@ export async function testHASource(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape returned by `POST /home-assistant/sources/{id}/test`. */
|
||||
interface HATestResult {
|
||||
success: boolean;
|
||||
ha_version?: string;
|
||||
entity_count?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ── Card rendering ──
|
||||
|
||||
export function createHASourceCard(source: HomeAssistantSource) {
|
||||
@@ -328,9 +318,7 @@ const _haSourceActions: Record<string, (id: string) => void> = {
|
||||
|
||||
async function _testHASourceFromCard(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<HATestResult>(`/home-assistant/sources/${sourceId}/test`);
|
||||
if (data.success) {
|
||||
showToast(`HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
|
||||
} else {
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
import {
|
||||
_cachedHTTPEndpoints, httpEndpointsCache,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -315,16 +316,10 @@ export async function saveHTTPEndpoint(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/http/endpoints/${id}` : '/http/endpoints';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/http/endpoints/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/http/endpoints', payload);
|
||||
}
|
||||
showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success');
|
||||
httpEndpointModal.forceClose();
|
||||
@@ -340,9 +335,7 @@ export async function saveHTTPEndpoint(): Promise<void> {
|
||||
|
||||
export async function editHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
|
||||
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
|
||||
const data: HTTPEndpoint = await resp.json();
|
||||
const data = await apiGet<HTTPEndpoint>(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') });
|
||||
await showHTTPEndpointModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -352,14 +345,14 @@ export async function editHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
|
||||
export async function cloneHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
|
||||
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
// Cloning never reveals the token — user must re-enter if needed.
|
||||
data.auth_token_set = false;
|
||||
await showHTTPEndpointModal(data);
|
||||
const data = await apiGet<HTTPEndpoint>(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showHTTPEndpointModal({
|
||||
...rest,
|
||||
name: `${data.name} (copy)`,
|
||||
// Cloning never reveals the token — user must re-enter if needed.
|
||||
auth_token_set: false,
|
||||
} as HTTPEndpoint);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -370,11 +363,7 @@ export async function deleteHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('http_endpoint.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/http/endpoints/${endpointId}`);
|
||||
showToast(t('http_endpoint.deleted'), 'success');
|
||||
httpEndpointsCache.invalidate();
|
||||
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
||||
@@ -427,13 +416,7 @@ export async function testHTTPEndpoint(): Promise<void> {
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/http/endpoints/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, method, auth_token: token, headers, timeout_s }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data: HTTPTestResponse = await resp.json();
|
||||
const data = await apiPost<HTTPTestResponse>('/http/endpoints/test', { url, method, auth_token: token, headers, timeout_s });
|
||||
_renderTestResult(out, data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -493,12 +476,7 @@ function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) {
|
||||
|
||||
async function _testHTTPEndpointFromCard(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const data: HTTPTestResponse = await resp.json();
|
||||
const data = await apiPost<HTTPTestResponse>(`/http/endpoints/${endpointId}/test`);
|
||||
if (data.success) {
|
||||
const status = data.status_code != null ? ` (${data.status_code})` : '';
|
||||
showToast(`${t('http_endpoint.test.success')}${status}`, 'success');
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiPut } from '../core/api-client.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import { devicesCache, outputTargetsCache } from '../core/state.ts';
|
||||
import {
|
||||
@@ -494,22 +495,14 @@ async function _applyChange(nextIconId: string, nextColor: string): Promise<void
|
||||
if (adapter.bodyExtras) {
|
||||
Object.assign(body, adapter.bodyExtras(entityId));
|
||||
}
|
||||
const resp = await fetchWithAuth(adapter.endpoint(entityId), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showToast((err && (err as any).detail) || t('device.icon.error.save_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
await apiPut(adapter.endpoint(entityId), body, { errorMessage: t('device.icon.error.save_failed') });
|
||||
if (nextIconId) _pushRecent(nextIconId);
|
||||
showToast(t('device.icon.saved') || 'Icon saved', 'success');
|
||||
await adapter.reload();
|
||||
closeIconPicker();
|
||||
} catch (error: any) {
|
||||
if (error?.isAuth) return;
|
||||
showToast(t('device.icon.error.save_failed') || 'Failed to save icon', 'error');
|
||||
showToast(error?.message || t('device.icon.error.save_failed') || 'Failed to save icon', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CardSection } from '../core/card-sections.ts';
|
||||
import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { updateSubTabHash } from './tabs.ts';
|
||||
import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiDelete } from '../core/api-client.ts';
|
||||
import { showToast, setTabRefreshing } from '../core/ui.ts';
|
||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
||||
@@ -29,10 +29,8 @@ import * as P from '../core/icon-paths.ts';
|
||||
|
||||
function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) {
|
||||
return async (ids: string[]) => {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/${endpoint}/${id}`, { method: 'DELETE' })
|
||||
));
|
||||
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
|
||||
const results = await Promise.allSettled(ids.map(id => apiDelete(`/${endpoint}/${id}`)));
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||
else showToast(t(toast), 'success');
|
||||
cache.invalidate();
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { mqttSourcesCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -144,16 +145,10 @@ export async function saveMQTTSource(): Promise<void> {
|
||||
if (password) payload.password = password;
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/mqtt/sources/${id}` : '/mqtt/sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/mqtt/sources/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/mqtt/sources', payload);
|
||||
}
|
||||
showToast(t(id ? 'mqtt_source.updated' : 'mqtt_source.created'), 'success');
|
||||
mqttSourceModal.forceClose();
|
||||
@@ -168,9 +163,7 @@ export async function saveMQTTSource(): Promise<void> {
|
||||
|
||||
export async function editMQTTSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('mqtt_source.error.load'));
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<MQTTSource>(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') });
|
||||
await showMQTTSourceModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -180,12 +173,9 @@ export async function editMQTTSource(sourceId: string): Promise<void> {
|
||||
|
||||
export async function cloneMQTTSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('mqtt_source.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showMQTTSourceModal(data);
|
||||
const data = await apiGet<MQTTSource>(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showMQTTSourceModal({ ...rest, name: `${data.name} (copy)` } as MQTTSource);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -196,11 +186,7 @@ export async function deleteMQTTSource(sourceId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('mqtt_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/mqtt/sources/${sourceId}`);
|
||||
showToast(t('mqtt_source.deleted'), 'success');
|
||||
mqttSourcesCache.invalidate();
|
||||
} catch (e: any) {
|
||||
@@ -219,9 +205,7 @@ export async function testMQTTSource(): Promise<void> {
|
||||
if (testBtn) testBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/mqtt/sources/${id}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<MQTTTestResult>(`/mqtt/sources/${id}/test`);
|
||||
if (data.success) {
|
||||
showToast(t('mqtt_source.test.success'), 'success');
|
||||
} else {
|
||||
@@ -235,11 +219,15 @@ export async function testMQTTSource(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape returned by `POST /mqtt/sources/{id}/test`. */
|
||||
interface MQTTTestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function _testMQTTSourceFromCard(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<MQTTTestResult>(`/mqtt/sources/${sourceId}/test`);
|
||||
if (data.success) {
|
||||
showToast(t('mqtt_source.test.success'), 'success');
|
||||
} else {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* settings.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
@@ -102,9 +102,7 @@ export async function startNotificationsWatcher(): Promise<void> {
|
||||
/** Pull the latest prefs from the server and cache them. */
|
||||
export async function refreshNotificationPreferences(): Promise<NotificationPreferences> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/preferences/notifications');
|
||||
if (!resp.ok) return _prefs;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>('/preferences/notifications');
|
||||
_prefs = { ...DEFAULT_PREFS, ...data, channels: { ...DEFAULT_PREFS.channels, ...(data.channels || {}) } };
|
||||
} catch (err) {
|
||||
logError('notifications.fetch', err);
|
||||
@@ -116,15 +114,7 @@ export async function refreshNotificationPreferences(): Promise<NotificationPref
|
||||
export async function saveNotificationPreferences(
|
||||
next: NotificationPreferences,
|
||||
): Promise<NotificationPreferences> {
|
||||
const resp = await fetchWithAuth('/preferences/notifications', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const saved = await resp.json();
|
||||
const saved = await apiPut<any>('/preferences/notifications', next);
|
||||
_prefs = { ...DEFAULT_PREFS, ...saved, channels: { ...DEFAULT_PREFS.channels, ...(saved.channels || {}) } };
|
||||
return _prefs;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
PATTERN_RECT_BORDERS,
|
||||
streamsCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, getHeaders, escapeHtml } from '../core/api.ts';
|
||||
import { apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { patternTemplatesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||
@@ -260,20 +261,10 @@ export async function savePatternTemplate(): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (templateId) {
|
||||
response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
|
||||
method: 'PUT', body: JSON.stringify(payload),
|
||||
});
|
||||
await apiPut(`/pattern-templates/${templateId}`, payload, { errorMessage: t('pattern.error.save_failed') });
|
||||
} else {
|
||||
response = await fetchWithAuth('/pattern-templates', {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || 'Failed to save');
|
||||
await apiPost('/pattern-templates', payload, { errorMessage: t('pattern.error.save_failed') });
|
||||
}
|
||||
|
||||
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
|
||||
@@ -305,20 +296,13 @@ export async function deletePatternTemplate(templateId: string): Promise<void> {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('pattern.deleted'), 'success');
|
||||
patternTemplatesCache.invalidate();
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.detail || t('pattern.error.delete_failed'), 'error');
|
||||
}
|
||||
await apiDelete(`/pattern-templates/${templateId}`, { errorMessage: t('pattern.error.delete_failed') });
|
||||
showToast(t('pattern.deleted'), 'success');
|
||||
patternTemplatesCache.invalidate();
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('pattern.error.delete_failed'), 'error');
|
||||
showToast(error.message || t('pattern.error.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* cheap for 120-sample lines.
|
||||
*/
|
||||
|
||||
import { fetchMetricsHistory, fetchWithAuth } from '../core/api.ts';
|
||||
import { fetchMetricsHistory } from '../core/api.ts';
|
||||
import { apiGet } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { dashboardPollInterval } from '../core/state.ts';
|
||||
import { isActiveTab } from '../core/tab-registry.ts';
|
||||
@@ -1102,9 +1103,7 @@ function _renderValuePair(key: string, sysVal: string, appVal: string | null): v
|
||||
|
||||
async function _fetchPerformance(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/performance');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<any>('/system/performance');
|
||||
_lastFetchData = data;
|
||||
_applyPerfDataToDom(data, /*pushHistory=*/true);
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Rendered as a CardSection inside the Automations tab, plus dashboard compact cards.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -135,10 +136,8 @@ export const csScenes = new CardSection('scenes', {
|
||||
bulkActions: [{
|
||||
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete',
|
||||
handler: async (ids) => {
|
||||
const results = await Promise.allSettled(ids.map(id =>
|
||||
fetchWithAuth(`/scene-presets/${id}`, { method: 'DELETE' })
|
||||
));
|
||||
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
|
||||
const results = await Promise.allSettled(ids.map(id => apiDelete(`/scene-presets/${id}`)));
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||
else showToast(t('scenes.deleted'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
@@ -384,37 +383,22 @@ export async function saveScenePreset(): Promise<void> {
|
||||
const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : [];
|
||||
|
||||
try {
|
||||
let resp;
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => (el as HTMLElement).dataset.targetId);
|
||||
const body = { name, description, target_ids, tags };
|
||||
if (_editingId) {
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => (el as HTMLElement).dataset.targetId);
|
||||
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
||||
});
|
||||
await apiPut(`/scene-presets/${_editingId}`, body, { errorMessage: t('scenes.error.save_failed') });
|
||||
} else {
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => (el as HTMLElement).dataset.targetId);
|
||||
resp = await fetchWithAuth('/scene-presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
errorEl.textContent = err.detail || t('scenes.error.save_failed');
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
await apiPost('/scene-presets', body, { errorMessage: t('scenes.error.save_failed') });
|
||||
}
|
||||
|
||||
scenePresetModal.forceClose();
|
||||
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
errorEl.textContent = t('scenes.error.save_failed');
|
||||
errorEl.textContent = error.message || t('scenes.error.save_failed');
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
@@ -488,17 +472,9 @@ export async function addSceneTarget(): Promise<void> {
|
||||
|
||||
export async function activateScenePreset(presetId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const detail = errData.detail || errData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('scenes.error.activate_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
const result = await resp.json();
|
||||
const result = await apiPost<{ status: string; errors: any[] }>(
|
||||
`/scene-presets/${presetId}/activate`, undefined, { errorMessage: t('scenes.error.activate_failed') },
|
||||
);
|
||||
if (result.status === 'activated') {
|
||||
showToast(t('scenes.activated'), 'success');
|
||||
} else {
|
||||
@@ -507,7 +483,7 @@ export async function activateScenePreset(presetId: string): Promise<void> {
|
||||
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('scenes.error.activate_failed'), 'error');
|
||||
showToast(error.message || t('scenes.error.activate_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,20 +496,11 @@ export async function recaptureScenePreset(presetId: string): Promise<void> {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.recaptured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const detail = errData.detail || errData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('scenes.error.recapture_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
await apiPost(`/scene-presets/${presetId}/recapture`, undefined, { errorMessage: t('scenes.error.recapture_failed') });
|
||||
showToast(t('scenes.recaptured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(error.message || t('scenes.error.recapture_failed'), 'error');
|
||||
}
|
||||
@@ -592,20 +559,11 @@ export async function deleteScenePreset(presetId: string): Promise<void> {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.deleted'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const detail = errData.detail || errData.message || '';
|
||||
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
|
||||
showToast(detailStr || t('scenes.error.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
await apiDelete(`/scene-presets/${presetId}`, { errorMessage: t('scenes.error.delete_failed') });
|
||||
showToast(t('scenes.deleted'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(error.message || t('scenes.error.delete_failed'), 'error');
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
audioTemplatesCache,
|
||||
apiKey,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -61,9 +62,7 @@ const audioTemplateModal = new AudioTemplateModal();
|
||||
|
||||
async function loadAvailableAudioEngines() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/audio-engines');
|
||||
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
|
||||
const data = await response.json();
|
||||
const data = await apiGet<{ engines?: any[] }>('/audio-engines');
|
||||
setAvailableAudioEngines(data.engines || []);
|
||||
|
||||
const select = document.getElementById('audio-template-engine') as HTMLSelectElement;
|
||||
@@ -232,9 +231,7 @@ export async function showAddAudioTemplateModal(cloneData: any = null) {
|
||||
|
||||
export async function editAudioTemplate(templateId: any) {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/audio-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
|
||||
const template = await response.json();
|
||||
const template = await apiGet<any>(`/audio-templates/${templateId}`);
|
||||
|
||||
setCurrentEditingAudioTemplateId(templateId);
|
||||
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`;
|
||||
@@ -284,16 +281,10 @@ export async function saveAudioTemplate() {
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (templateId) {
|
||||
response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
await apiPut(`/audio-templates/${templateId}`, payload, { errorMessage: t('audio_template.error.save_failed') });
|
||||
} else {
|
||||
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save audio template');
|
||||
await apiPost('/audio-templates', payload, { errorMessage: t('audio_template.error.save_failed') });
|
||||
}
|
||||
|
||||
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
|
||||
@@ -312,11 +303,7 @@ export async function deleteAudioTemplate(templateId: any) {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to delete audio template');
|
||||
}
|
||||
await apiDelete(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.delete') });
|
||||
showToast(t('audio_template.deleted'), 'success');
|
||||
audioTemplatesCache.invalidate();
|
||||
await loadAudioTemplates();
|
||||
@@ -328,11 +315,9 @@ export async function deleteAudioTemplate(templateId: any) {
|
||||
|
||||
export async function cloneAudioTemplate(templateId: any) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load audio template');
|
||||
const tmpl = await resp.json();
|
||||
const tmpl = await apiGet<any>(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.load_failed') });
|
||||
showAddAudioTemplateModal(tmpl);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to clone audio template:', error);
|
||||
showToast(t('audio_template.error.clone_failed'), 'error');
|
||||
@@ -364,21 +349,18 @@ export async function showTestAudioTemplateModal(templateId: any) {
|
||||
// Load audio devices for picker — filter by engine type
|
||||
const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/audio-devices');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
// Use engine-specific device list if available, fall back to flat list
|
||||
const devices = (engineType && data.by_engine && data.by_engine[engineType])
|
||||
? data.by_engine[engineType]
|
||||
: (data.devices || []);
|
||||
deviceSelect.innerHTML = devices.map(d => {
|
||||
const label = d.name;
|
||||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||||
return `<option value="${val}">${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
if (devices.length === 0) {
|
||||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||||
}
|
||||
const data = await apiGet<{ by_engine?: Record<string, any[]>; devices?: any[] }>('/audio-devices');
|
||||
// Use engine-specific device list if available, fall back to flat list
|
||||
const devices = (engineType && data.by_engine && data.by_engine[engineType])
|
||||
? data.by_engine[engineType]
|
||||
: (data.devices || []);
|
||||
deviceSelect.innerHTML = devices.map(d => {
|
||||
const label = d.name;
|
||||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||||
return `<option value="${val}">${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
if (devices.length === 0) {
|
||||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||||
}
|
||||
} catch {
|
||||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
captureTemplatesCache, displaysCache, enginesCache,
|
||||
apiKey,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, getHeaders, escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm, openLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts';
|
||||
@@ -105,9 +106,7 @@ export async function showAddTemplateModal(cloneData: any = null) {
|
||||
|
||||
export async function editTemplate(templateId: any) {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||||
const template = await response.json();
|
||||
const template = await apiGet<any>(`/capture-templates/${templateId}`);
|
||||
|
||||
setCurrentEditingTemplateId(templateId);
|
||||
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
|
||||
@@ -414,9 +413,7 @@ async function loadDisplaysForTest() {
|
||||
|
||||
// Always refetch for engines with own displays (devices may change); use cache for desktop
|
||||
if (!_cachedDisplays || engineHasOwnDisplays) {
|
||||
const response = await fetchWithAuth(url);
|
||||
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
|
||||
const displaysData = await response.json();
|
||||
const displaysData = await apiGet<{ displays?: any[] }>(url);
|
||||
displaysCache.update(displaysData.displays || []);
|
||||
}
|
||||
|
||||
@@ -607,16 +604,10 @@ export async function saveTemplate() {
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (templateId) {
|
||||
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
await apiPut(`/capture-templates/${templateId}`, payload, { errorMessage: t('templates.error.save_failed') });
|
||||
} else {
|
||||
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save template');
|
||||
await apiPost('/capture-templates', payload, { errorMessage: t('templates.error.save_failed') });
|
||||
}
|
||||
|
||||
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
||||
@@ -635,11 +626,7 @@ export async function deleteTemplate(templateId: any) {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||
}
|
||||
await apiDelete(`/capture-templates/${templateId}`, { errorMessage: t('templates.error.delete') });
|
||||
showToast(t('templates.deleted'), 'success');
|
||||
captureTemplatesCache.invalidate();
|
||||
await loadCaptureTemplates();
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { _cachedSyncClocks, syncClocksCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -124,16 +125,10 @@ export async function saveSyncClock(): Promise<void> {
|
||||
const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/sync-clocks/${id}` : '/sync-clocks';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/sync-clocks/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/sync-clocks', payload);
|
||||
}
|
||||
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
|
||||
syncClockModal.forceClose();
|
||||
@@ -149,9 +144,7 @@ export async function saveSyncClock(): Promise<void> {
|
||||
|
||||
export async function editSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
|
||||
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<SyncClock>(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') });
|
||||
await showSyncClockModal(data);
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -161,12 +154,9 @@ export async function editSyncClock(clockId: string): Promise<void> {
|
||||
|
||||
export async function cloneSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
|
||||
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showSyncClockModal(data);
|
||||
const data = await apiGet<SyncClock>(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showSyncClockModal({ ...rest, name: `${data.name} (copy)` } as SyncClock);
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -177,11 +167,7 @@ export async function deleteSyncClock(clockId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('sync_clock.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/sync-clocks/${clockId}`);
|
||||
showToast(t('sync_clock.deleted'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
@@ -195,8 +181,7 @@ export async function deleteSyncClock(clockId: string): Promise<void> {
|
||||
|
||||
export async function pauseSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await apiPost(`/sync-clocks/${clockId}/pause`);
|
||||
showToast(t('sync_clock.paused'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
@@ -208,8 +193,7 @@ export async function pauseSyncClock(clockId: string): Promise<void> {
|
||||
|
||||
export async function resumeSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await apiPost(`/sync-clocks/${clockId}/resume`);
|
||||
showToast(t('sync_clock.resumed'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
@@ -221,8 +205,7 @@ export async function resumeSyncClock(clockId: string): Promise<void> {
|
||||
|
||||
export async function resetSyncClock(clockId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await apiPost(`/sync-clocks/${clockId}/reset`);
|
||||
showToast(t('sync_clock.reset_done'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Auto-update — check for new releases, show banner, manage settings.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
@@ -129,10 +129,7 @@ export function dismissUpdate(): void {
|
||||
_hideBanner();
|
||||
_setVersionBadgeUpdate(false);
|
||||
|
||||
fetchWithAuth('/system/update/dismiss', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ version }),
|
||||
}).catch(() => {});
|
||||
apiPost('/system/update/dismiss', { version }).catch(() => {});
|
||||
}
|
||||
|
||||
// ─── Apply update ───────────────────────────────────────────
|
||||
@@ -151,14 +148,7 @@ export async function applyUpdate(): Promise<void> {
|
||||
btns.forEach(b => (b as HTMLButtonElement).disabled = true);
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/apply', {
|
||||
method: 'POST',
|
||||
timeout: 600000, // 10 min for download + apply
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiPost('/system/update/apply', undefined, { timeout: 600000 /* 10 min for download + apply */ });
|
||||
// Server will shut down — the frontend reconnect overlay handles the rest
|
||||
showToast(t('update.applying'), 'info');
|
||||
} catch (err) {
|
||||
@@ -171,9 +161,7 @@ export async function applyUpdate(): Promise<void> {
|
||||
|
||||
export async function loadUpdateStatus(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/status');
|
||||
if (!resp.ok) return;
|
||||
const status: UpdateStatus = await resp.json();
|
||||
const status = await apiGet<UpdateStatus>('/system/update/status');
|
||||
_lastStatus = status;
|
||||
_applyStatus(status);
|
||||
} catch {
|
||||
@@ -260,12 +248,7 @@ export async function checkForUpdates(): Promise<void> {
|
||||
if (spinner) spinner.style.display = '';
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/check', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const status: UpdateStatus = await resp.json();
|
||||
const status = await apiPost<UpdateStatus>('/system/update/check');
|
||||
_lastStatus = status;
|
||||
_applyStatus(status);
|
||||
|
||||
@@ -350,9 +333,7 @@ export function initUpdateSettingsPanel(): void {
|
||||
|
||||
export async function loadUpdateSettings(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<{ enabled: boolean; check_interval_hours: number; include_prerelease: boolean }>('/system/update/settings');
|
||||
|
||||
const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
|
||||
const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null;
|
||||
@@ -388,14 +369,7 @@ export async function saveUpdateSettings(): Promise<void> {
|
||||
if (Number.isNaN(check_interval_hours)) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled, check_interval_hours, include_prerelease }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiPut('/system/update/settings', { enabled, check_interval_hours, include_prerelease });
|
||||
} catch (err) {
|
||||
showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { _cachedWeatherSources, weatherSourcesCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
@@ -166,16 +167,10 @@ export async function saveWeatherSource(): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/weather-sources/${id}` : '/weather-sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
if (id) {
|
||||
await apiPut(`/weather-sources/${id}`, payload);
|
||||
} else {
|
||||
await apiPost('/weather-sources', payload);
|
||||
}
|
||||
showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success');
|
||||
weatherSourceModal.forceClose();
|
||||
@@ -191,9 +186,7 @@ export async function saveWeatherSource(): Promise<void> {
|
||||
|
||||
export async function editWeatherSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('weather_source.error.load'));
|
||||
const data = await resp.json();
|
||||
const data = await apiGet<WeatherSource>(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') });
|
||||
await showWeatherSourceModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -203,12 +196,9 @@ export async function editWeatherSource(sourceId: string): Promise<void> {
|
||||
|
||||
export async function cloneWeatherSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('weather_source.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showWeatherSourceModal(data);
|
||||
const data = await apiGet<WeatherSource>(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showWeatherSourceModal({ ...rest, name: `${data.name} (copy)` } as WeatherSource);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -219,11 +209,7 @@ export async function deleteWeatherSource(sourceId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('weather_source.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
await apiDelete(`/weather-sources/${sourceId}`);
|
||||
showToast(t('weather_source.deleted'), 'success');
|
||||
weatherSourcesCache.invalidate();
|
||||
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
||||
@@ -243,9 +229,7 @@ export async function testWeatherSource(): Promise<void> {
|
||||
if (testBtn) testBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${id}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<WeatherTestResult>(`/weather-sources/${id}/test`);
|
||||
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
@@ -255,6 +239,13 @@ export async function testWeatherSource(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape returned by `POST /weather-sources/{id}/test`. */
|
||||
interface WeatherTestResult {
|
||||
condition: string;
|
||||
temperature: number;
|
||||
wind_speed: number;
|
||||
}
|
||||
|
||||
// ── Geolocation ──
|
||||
|
||||
export function weatherSourceGeolocate(): void {
|
||||
@@ -336,9 +327,7 @@ const _weatherSourceActions: Record<string, (id: string) => void> = {
|
||||
|
||||
async function _testWeatherSourceFromCard(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const data = await apiPost<WeatherTestResult>(`/weather-sources/${sourceId}/test`);
|
||||
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
colorStripSourcesCache, mqttSourcesCache,
|
||||
outputTargetsCache, valueSourcesCache,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { escapeHtml } from '../core/api.ts';
|
||||
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
import { safeJsonParse } from '../core/storage.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
@@ -303,9 +304,7 @@ export async function showZ2MLightEditor(targetId: string | null = null, cloneDa
|
||||
let editData: any = null;
|
||||
if (isEdit) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
editData = await resp.json();
|
||||
editData = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -463,13 +462,10 @@ export async function saveZ2MLightEditor(): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = targetId
|
||||
? await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload) })
|
||||
: await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload) });
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${response.status}`);
|
||||
if (targetId) {
|
||||
await apiPut(`/output-targets/${targetId}`, payload);
|
||||
} else {
|
||||
await apiPost('/output-targets', payload);
|
||||
}
|
||||
showToast(targetId ? t('z2m_light.updated') : t('z2m_light.created'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
@@ -489,12 +485,9 @@ export async function editZ2MLightTarget(targetId: string): Promise<void> {
|
||||
|
||||
export async function cloneZ2MLightTarget(targetId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showZ2MLightEditor(null, data);
|
||||
const data = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||
const { id: _omit, ...rest } = data;
|
||||
await showZ2MLightEditor(null, { ...rest, name: `${data.name} (copy)` });
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
@@ -659,8 +652,7 @@ const _z2mLightActions: Record<string, (id: string) => void> = {
|
||||
|
||||
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await apiPost(`/output-targets/${targetId}/${action}`);
|
||||
outputTargetsCache.invalidate();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} catch (e: any) {
|
||||
@@ -673,16 +665,13 @@ export async function turnOffZ2MLightTarget(targetId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('confirm.turn_off_z2m_light') || 'Turn off mapped bulbs?');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}/z2m-light/turn-off`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success');
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showToast(err.detail || t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error');
|
||||
}
|
||||
await apiPost(`/output-targets/${targetId}/z2m-light/turn-off`, undefined, {
|
||||
errorMessage: t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs',
|
||||
});
|
||||
showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success');
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error');
|
||||
showToast(e.message || t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Asset shapes — uploaded/prebuilt media (images, video, sound) keyed by
|
||||
* id and referenced from static-image / video / notification sources.
|
||||
*/
|
||||
|
||||
export interface Asset {
|
||||
id: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
asset_type: string;
|
||||
size_bytes: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
prebuilt: boolean;
|
||||
icon?: string;
|
||||
icon_color?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AssetListResponse {
|
||||
assets: Asset[];
|
||||
count: number;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Audio source shapes — capture (device) and processed (template-driven)
|
||||
* variants, discriminated on `source_type`.
|
||||
*/
|
||||
|
||||
export type AudioSourceType = 'capture' | 'processed';
|
||||
|
||||
interface AudioSourceBase {
|
||||
id: string;
|
||||
name: string;
|
||||
source_type: AudioSourceType;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
icon?: string;
|
||||
icon_color?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CaptureAudioSource extends AudioSourceBase {
|
||||
source_type: 'capture';
|
||||
device_index: number;
|
||||
is_loopback: boolean;
|
||||
audio_template_id?: string;
|
||||
}
|
||||
|
||||
export interface ProcessedAudioSource extends AudioSourceBase {
|
||||
source_type: 'processed';
|
||||
audio_source_id: string;
|
||||
audio_processing_template_id: string;
|
||||
}
|
||||
|
||||
export type AudioSource =
|
||||
| CaptureAudioSource
|
||||
| ProcessedAudioSource;
|
||||
|
||||
export interface AudioSourceListResponse {
|
||||
sources: AudioSource[];
|
||||
count: number;
|
||||
}
|
||||