Compare commits

...

11 Commits

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

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

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

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

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

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

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

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

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

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

Capture POINTER(MSG) once into LPMSG and reuse the same class object
across all three prototypes in both the WindowsShutdownGuard pump and
the PlatformDetector display-power monitor. Restores the 4 failing
win_shutdown tests.
2026-05-28 17:25:37 +03:00
alexei.dolgolyov bb3a316e35 refactor(frontend): shared API client + automations registry (audit M7, H8)
H8 — automations.ts rule-type registry
  Convert the two hand-rolled RuleType dispatch ladders into per-type
  registries (RULE_FIELD_RENDERERS + RULE_COLLECTORS) keyed by RuleType,
  joining the existing RULE_CHIP_RENDERERS. All three are typed
  Record<RuleType, ...> for compile-time exhaustiveness; an import-time
  _assertRuleHandlerCoverage() check logs loudly if any registry drifts
  from RULE_TYPE_KEYS — mirrors the backend's _RULE_HANDLERS shape, the
  one intentional divergence being that the frontend logs rather than
  throws (a thrown error at module import would brick the whole bundle,
  not just the editor).

M7 — shared API client + 35 file migrations
  New core/api-client.ts wrapping fetchWithAuth with typed apiGet /
  apiPost / apiPut / apiPatch / apiDelete. Auth, 401-relogin, retry,
  timeout, and the offline toast all stay owned by fetchWithAuth; the
  client just collapses the
  if (!resp.ok) { detail || HTTP <status> } ... resp.json()
  dance into one typed call. The detail unwrap is hardened to join
  FastAPI validation arrays instead of stringifying to [object Object].

  35 feature/core files migrated to it across many batches, reviewer-
  approved for behaviour parity in three passes covering the riskier
  divergences (bulk Promise.allSettled deletes, inline-error saves,
  array-detail joins, silent-failure GETs, immutable clones).

  9 files remain on fetchWithAuth — the big god-modules tied to the
  pending C8/C9/C10 splits (streams, settings, targets, dashboard,
  color-strips/index, graph-editor, assets, value-sources) plus
  pairing-flow which by design stays on raw fetch (branches on raw
  Response.status codes).

i18n — 14 new locale keys (en / ru / zh)
  Added save/load/delete error keys across automations, pattern,
  audio_processing, audio_template, templates, gradient, target,
  device namespaces, plus backfilled gradient.error.delete_failed into
  ru/zh. Scan confirms no hardcoded English errorMessage strings
  remain in the migrated diff.

AUDIT_REMAINING.md updated to reflect H6, H8, and M7 status.

Verified: tsc --noEmit clean + npm run build clean after every batch.
2026-05-28 14:58:08 +03:00
alexei.dolgolyov 49c35a2ea0 refactor(frontend): split types.ts into 18 per-entity files (audit H6)
Convert the 1140-LOC types.ts into a pure re-export barrel backed by
focused per-entity files under types/, joining the existing
bindable.ts. Every import { ... } from '../types.ts' resolves
unchanged; reviewer-confirmed all 102 type exports preserved.
2026-05-28 14:57:25 +03:00
79 changed files with 3477 additions and 2567 deletions
+26 -1
View File
@@ -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"
+92 -11
View File
@@ -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
+45 -140
View File
@@ -1,167 +1,72 @@
## v0.7.0 (2026-05-26)
## v0.8.0 (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
##### Android TV — production-readiness pass
- **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))
- **Security:** per-install random API key (persisted, threaded into the embedded server via env, embedded in the pairing QR as a URL-fragment so it never reaches HTTP logs); root-shell injection eliminated via POSIX-quoted `runAsRoot(argv)` overload; broadcast receivers locked to the app package; release builds refuse to silently sign with the debug keystore; crash log retention capped at 10 entries
- **Performance:** single reusable RGBA buffer in `ScreenCapture` / `RootScreenrecord` (eliminates ~15 MB/s GC churn at 30 fps); frame pacer switched to `elapsedRealtimeNanos` with catch-up accumulator (fixes ~30.3 fps drift); capture dimensions derived from source aspect ratio so non-16:9 displays aren't squashed; QR bitmap cached by URL
- **Compatibility:** compileSdk/targetSdk → 35 (Play Store requirement); armeabi-v7a build path; foreground service type declared as `mediaProjection|specialUse` with proper `ServiceCompat.startForeground` promotion; Ethernet > Wi-Fi > VPN > cellular selection in `NetworkUtils`; Android 15 predictive-back via `enableOnBackInvokedCallback`; splash screen API hides Chaquopy cold-start delay
- **UI/UX:** all hardcoded English strings localised across en/ru/zh; monochrome notification icon; 320×180 TV banner; ViewStub-based running panel; pulse animator on Running dot; "Starting…" button while probing root; autostart checkbox hidden on unrooted devices
- **Lifecycle hardening:** `processLock` serialises EOF respawn vs `stop()` to prevent orphaned screenrecord; publish-before-start under `@Synchronized` in `CaptureService.restartRootPipeline` closes the orphan window during watchdog restarts; watchdog give-up bound corrected ([ef1f9ea](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ef1f9ea))
#### New output type
##### Backup format — bundled DB + assets ZIP
- **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))
- Auto-backups now produce a `.zip` containing `ledgrab.db` plus every file from the assets directory under `assets/` — matching the manual `GET /api/v1/system/backup` download. Restore accepts both `.zip` and legacy `.db` interchangeably
- Partial-write hardening: writes stage to `<name>.partial` then `os.replace` into place — a crash mid-write never leaves a corrupt backup masquerading as valid. Stale `.partial` files from prior crashes are swept on the next run
- Symlinks inside the assets directory are skipped (security guard against link targets outside the dir)
- Backups over 500 MB log a warning so operators notice unbounded asset growth before disk fills up
- `restart.py` redirects spawned restart script stdout/stderr to `restart.log` and bails out early if the script is missing — silent failures used to vanish into a detached child ([85da2e5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/85da2e5))
#### Pairing flow
##### Spectrum-aperture icon set
- 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))
- Regenerated icon family from a single Pillow script: rounded-square aperture traced by a continuous RGB color-wheel stroke over a vignette canvas with chromatic bloom. 4× supersampled then downsampled per output for crispness
- New 256 px transparent-background **tray variant** — taskbar icon reads cleanly against light themes instead of showing a dark tile
- `icon.ico` now embeds 16/24/32/48/64/128/256 frames sourced from the transparent master (fixes the dark-square halo on light Windows themes)
- Maskable 512 variant safe-area padded for PWA round-crops ([3645216](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3645216))
#### 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))
- **Notification sound dropdowns:** both the per-app override list and the main row now always render the EntitySelect (was silently inert before any sound assets were registered) and offer "no sound" as a first-class option via `allowNone` ([1f95993](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1f95993))
- **CSS editor:** `notification_sound` and `notification_volume` are now persisted on save — they were silently dropped from the payload before ([66b85b0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/66b85b0))
- **Python 3.13 ctypes:** Win32 message-pump prototypes (`GetMessageW` / `TranslateMessage` / `DispatchMessageW`) now share a single `LPMSG = POINTER(wintypes.MSG)` class across `WindowsShutdownGuard` and `PlatformDetector` — fixes the `expected LP_MSG instance instead of pointer to _MSG` error and the resulting shutdown-guard / display-power-monitor failure on 3.13 ([e4d24a0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4d24a0), [0d840ad](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0d840ad))
---
### Development / Internal
#### Architecture audit — registry patterns everywhere
#### CI/Build
- **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))
- `release.yml` now creates the Gitea release as a **draft** and only flips `draft=false` once every build job (Windows, Linux, Docker) has uploaded its artifacts and sha256 sidecars — users never see a release page that's missing assets, which would have broken the in-app updater ([bc42604](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bc42604))
#### Dedup / refactor
#### Refactoring
- **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))
- **Shared API client + automations registry (audit M7, H8):** new `core/api-client.ts` wraps `fetchWithAuth` with typed `apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete`; 35 feature/core files migrated. FastAPI validation-array detail unwrap hardened. Automations editor's two hand-rolled `RuleType` dispatch ladders converted to `Record<RuleType, ...>` registries with an import-time exhaustiveness check ([bb3a316](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bb3a316))
- **types.ts split (audit H6):** 1140 LOC `types.ts` split into 18 per-entity files under `types/`, original file kept as a pure re-export barrel — 102 type exports preserved with no import sites changed ([49c35a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49c35a2))
#### Tests
#### Documentation
- 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))
- `REVIEW_RECONCILE_NOTES.md` — design doc for the dashboard innerHTML reconciliation work: bug-class analysis, latent-site inventory, decision ladder (helper / hand-rolled cells / Lit), and recommendation to migrate polling-heavy modules to Lit with `entity-events.ts` tab reconciliation sequenced first ([10eb24b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/10eb24b))
---
<details>
<summary>All Commits (55)</summary>
<summary>All Commits (11)</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 |
| ---- | ------- | ------ |
| [0d840ad](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0d840ad) | fix(ctypes): share wintypes.MSG with platform_detector to avoid argtype races | alexei.dolgolyov |
| [1f95993](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1f95993) | fix(notification): allow clearing the sound on per-app overrides and main row | alexei.dolgolyov |
| [10eb24b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/10eb24b) | docs: dashboard innerHTML reconciliation review notes | alexei.dolgolyov |
| [66b85b0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/66b85b0) | fix(css-editor): persist notification_sound + notification_volume | alexei.dolgolyov |
| [bc42604](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bc42604) | ci(release): publish release only after every build job uploads assets | alexei.dolgolyov |
| [3645216](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3645216) | feat(icons): spectrum aperture icon set + dedicated tray variant | alexei.dolgolyov |
| [85da2e5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/85da2e5) | feat(backup): bundle assets in ZIP + partial-write hardening + restart log | alexei.dolgolyov |
| [e4d24a0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4d24a0) | fix(ctypes): pin LPMSG across MSG-pump prototypes for Python 3.13 | alexei.dolgolyov |
| [bb3a316](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bb3a316) | refactor(frontend): shared API client + automations registry (audit M7, H8) | alexei.dolgolyov |
| [49c35a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49c35a2) | refactor(frontend): split types.ts into 18 per-entity files (audit H6) | alexei.dolgolyov |
| [ef1f9ea](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ef1f9ea) | feat(android): production-readiness pass — security, perf, compat, UI/UX | alexei.dolgolyov |
</details>
+249
View File
@@ -0,0 +1,249 @@
# Dashboard Reconciliation — Review Notes
*Captured 2026-05-26. Session focused on dashboard + perf-card flicker and per-poll re-rendering.*
*Updated 2026-05-27 — widened the audit beyond the two poll timers and found a **second driver** (server push) plus the **highest-blast-radius site** (`entity-events.ts`). Added §3.5, corrected the "out of scope" reasoning in §5, and confirmed the decision: **commit to the Lit migration**. Implementation deferred — this is still a planning doc, not a spec.*
This is a thinking-aloud document for whoever picks up reconciliation work next (likely me). It captures the bug class, what's already shipped, what's still latent, the decision ladder we walked through, and the recommendation we landed on. It is **not** a spec — treat any code shown as illustrative.
---
## 1. The bug class in one sentence
> Every place a data-driven render — a poll timer **or** a server-pushed `server:*` event — writes `el.innerHTML = ...`, the existing DOM is torn down — even when the new HTML equals the old — which restarts CSS animations, drops focus, skips transitions, and burns wasted DOM mutation cycles.
The symptom only becomes visually loud when the destroyed subtree contains a CSS keyframe animation (e.g. the pulsing `.perf-patches-empty-dot`). Everywhere else the cost is silent: lost transitions, broken focus, wasted layout work. The bug is **load-bearing in the architecture**, not in any single call site — that's why we keep coming back to it.
---
## 2. What landed in commit `f6486f9` (this session)
Tactical work — solves the worst cases, does not change the architecture.
### `server/src/ledgrab/static/js/features/dashboard.ts`
- Collapsed the two fast-path branches into one. Fast path runs when `structureUnchanged && !forceFullRender` regardless of `running.length`. Previously, **zero running targets meant every poll rebuilt the entire dashboard** even when nothing changed.
- `_lastSyncClockIds` no longer fingerprints `is_running` — pausing/resuming a clock no longer tears down every card. `_updateSyncClocksInPlace` already handles the toggle.
- `_updateAutomationsInPlace` now called from the unified fast path. Automation badges were silently going stale on the fast path.
- `_initFpsCharts` rewritten diff-based: only destroy charts for ids that left or whose canvas was detached by a DOM swap; only create for new ids; only fetch `/api/metrics/history` when there are genuinely new ids needing seed data.
- Sync-clock pause/resume/reset callers + `server:automation_state_changed` SSE handler now use `loadDashboard()` (no force) — `forceFullRender` is now actually load-bearing, meaning "settings changed, full rebuild required."
### `server/src/ledgrab/static/js/features/perf-charts.ts`
- `_renderChartSvg` no longer rewrites `innerHTML` per poll. The SVG skeleton (ref line + sys area/line + app line) is built once via `_ensureSparkNodes` and mutated thereafter. WeakMap cache (`_sparkNodeCache`) keyed by host element avoids the per-tick `querySelector` cost.
- Hidden cards (env-disabled GPU/Temp) skip render entirely.
- `_fetchPerformance` switched to `fetchWithAuth`.
- Hardcoded English strings replaced with `t()` calls. New keys: `perf.no_captures`, `perf.captures_count.{one,few,many,other}`, `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`, `perf.tip.now`, `perf.tip.ago` (en/ru/zh).
- Tooltip reads `dashboardPollInterval` per mousemove tick (was captured at bind time).
- Dead `<defs><linearGradient>` block removed.
- `updateTotalCaptureFpsActual` now delegates to `_paintCaptureFpsActualValue` — single code path.
- `updateActivePatches` / `updateDevices` skip the `innerHTML` write when content signature hasn't changed. This is the direct fix for the "READY TO LAUNCH flickers every update" report — the empty-state dot's CSS pulse no longer resets.
- Two missing semicolons in `_seedAggregateHistories` (ASI was saving us).
### Reviewer findings addressed (typescript-reviewer pass)
- **HIGH:** `_metricLabel` was looking up `dashboard.perf.${key}` but the FPS family uses `dashboard.perf.total_fps`, `total_capture_fps`, `total_capture_fps_actual`. Tooltip would have shouted `FPS` / `CAPTURE_FPS` / `CAPTURE_FPS_ACTUAL`. Fixed via explicit `METRIC_LABEL_KEYS` map.
- **HIGH:** `_ensureSparkNodes` silently coerced `null` children to non-null when the SVG existed but a child was missing. Hardened to validate all four children and rebuild if any are missing.
---
## 3. Hot spots still latent
These are the call sites where `innerHTML` is still written every poll. None are flickering today (no CSS animations on their inner elements), but every one is the same bug shape and will bite the next time someone adds a keyframe / transition / focus target inside.
### `perf-charts.ts`
| Line | Site | Fires per poll? | Notes |
|------|------|-----------------|-------|
| 462 | `updateActivePatches``listEl.innerHTML` | yes | guarded by signature compare (✓) |
| 493 | `updateTotalFps``valEl.innerHTML` | yes | FPS value, no inner animation |
| 526 | `updateTotalCaptureFps``valEl.innerHTML` | yes | same |
| 638 | `_paintNetworkValue``valEl.innerHTML` | yes | bytes/s value |
| 655 | `_paintDeviceLatencyValue``valEl.innerHTML` (no-devices hint) | yes | hint span |
| 657 | `_paintDeviceLatencyValue``valEl.innerHTML` (offline hint) | yes | hint span |
| 660 | `_paintDeviceLatencyValue``valEl.innerHTML` (ms value) | yes | value |
| 676 | `_paintSendTimingValue``valEl.innerHTML` (idle hint) | yes | hint span |
| 679 | `_paintSendTimingValue``valEl.innerHTML` (ms value) | yes | value |
| 738 | `_paintErrorsValue``valEl.innerHTML` | yes | rate value |
| 806 | `updateDevices``dotsEl.innerHTML` | yes | guarded by signature compare (✓) |
| 1086 | `_renderValuePair``mainEl.innerHTML = appVal` | yes | dual sys/app value |
| 1088 | `_renderValuePair``mainEl.innerHTML = sysVal` | yes | dual sys/app value |
| 1094 | `_renderValuePair``tagEl.innerHTML` (App tag) | mode='both' only | App tag in `both` mode |
| 1181 | `_applyPerfDataToDom` temp hint | only when cpu_temp_hint_key changes | rare |
| 1449 | `_paintFpsValue` | seed only | once per init |
| 1456 | `_paintCaptureFpsValue` | seed only | once per init |
| 1463 | `_paintCaptureFpsActualValue` (no-captures hint) | yes via live updater | now goes through painter |
| 1469 | `_paintCaptureFpsActualValue` (value) | yes via live updater | same |
| 1499 | `_paintErrorsValue` (duplicate of 738) | seed only | once per init |
| 1823 | tooltip `tip.innerHTML` | per mousemove | rate-limited by hover only |
### `dashboard.ts`
| Line | Site | Fires per poll? | Notes |
|------|------|-----------------|-------|
| 275 | `_updateRunningMetrics``fpsEl.innerHTML` | per running target | live FPS pill — visible churn |
| 293 | `_updateRunningMetrics``labelEl.innerHTML` (errors label) | per running target | rebuilt each poll |
| 340 | `_updateAutomationsInPlace``btn.innerHTML` | only on enable/disable change | low frequency |
| 366 | `_updateSyncClocksInPlace``btn.innerHTML` | per poll for every clock | wasteful |
| 975 | `loadDashboard` first-load → `container.innerHTML` | once per init | fine |
| 989 | `loadDashboard` slow path → `dynamic.innerHTML = dynamicHtml` | only when slow path fires | the **big** swap, scoped already |
| 1010 | `loadDashboard` error path | rare | fine |
| 1416 | `subscribeDashboardLayout` clear | rare | fine |
### What this list tells us
- The remaining innerHTML writes are **per-cell value updates** that paint formatted spans (`{value}<span class="perf-fps-unit">fps</span>`). Each rewrite destroys two text nodes + a span every poll across ~10 cells. Not flickering today; will flicker the moment anyone adds an animation to `.perf-fps-unit` or `.perf-fps-ceiling`.
- The pattern can be killed without architectural change by splitting these into a stable structure (number text node + static unit span) and only updating `textContent` of the number. That's what L3 / Lit would force naturally.
---
## 3.5 Beyond dashboard/perf — push-driven reconciliation
*Added 2026-05-27. The §3 audit was scoped to the two poll timers we were debugging. Widening the `\.innerHTML\s*=` search showed the bug class has a **second driver** and lives outside dashboard/perf too.*
### Two drivers, not one
The teardown is triggered by anything that re-renders **without user intent**:
- **Poll timers** (`setInterval`) — what §2/§3 covered (`dashboard.ts` `_uptimeTimer` + main refresh, `perf-charts.ts` `_pollTimer`).
- **Server-pushed `server:*` events** — `core/events-ws.ts` turns each WS message into a `server:*` CustomEvent; feature modules listen and re-render through the *same* `innerHTML` paths.
So the one-line bug class in §1 reads "poll- **or** push-driven," not just poll.
### Genuinely-affected sites outside dashboard/perf
| Site | Driver | Shape | Notes |
| ---- | ------ | ----- | ----- |
| `core/entity-events.ts` `_invalidateAndReload` | push (`server:entity_changed`, `server:device_health_changed`) | full-**tab** rebuild via `loadTargetsTab` / `loadPictureSources` / `loadAutomations` / `loadIntegrations` | **highest blast radius.** A single pushed entity change tears down and rebuilds an entire tab — losing scroll, focus, open inline editors, restarting card-enter animations. |
| `features/game-integration.ts` event feed (`_eventMonitorTimer`) | poll (2 s) | `feed.innerHTML = events.slice(0,20).map(...)` | full 20-item list rebuild every 2 s while the panel is open. |
| `features/game-integration.ts` connection test (`_connectionTestTimer`) | poll | `panel.innerHTML = …` per tick | transient, low frequency. |
`entity-events.ts` already has the **L1 floor applied by hand**: a 600 ms debounce plus a diff check (`oldData === newData`, then length + `id` + `updated_at` compare) that skips the reload when nothing changed. That kills the *no-op* case — but a **real** change still does the full-tab teardown. This is exactly the §4-L1 limitation ("still tears down when content *does* differ"), live across the whole app.
### Counter-examples that already do it right
Two poll loops never flicker because they mutate `textContent` on a **stable structure** instead of rewriting `innerHTML`:
- `core/api.ts` `loadServerInfo` (connection-check poll) — `versionEl.textContent` / `statusEl.textContent`.
- `features/color-strips/test.ts` FPS sampler (1 s) — `valueEl.textContent` / `avgEl.textContent`.
These are live proof that "stable structure + mutate text node" is the fix — i.e. what L3 / Lit force by construction.
### What this changes about the plan
The §4 ladder was reasoned entirely around **per-cell** rendering, because that was the visible flicker. The push-driven finding surfaces a second, qualitatively different problem:
- **Problem A — cell value churn:** every poll, one value span. Loud only with animations. *Mostly fixed in `f6486f9`.* → wants `setText` / skip-if-unchanged.
- **Problem B — list/tab teardown:** on change/push, an entire list or tab. Loses scroll/focus/open editors. *Unaddressed.* `entity-events.ts` and the game feed are Problem B. → wants **keyed list reconciliation**.
Problem B is a **list-level** concern, not a cell-level one. In Lit terms it maps to a keyed `repeat()` directive over the tab/list body — the dashboard-card work in Phase 2 already needs this, but `entity-events.ts` needs it for tabs that §5 used to list as "out of scope." This does **not** change the chosen direction (Lit); it adds `entity-events.ts` as a first-class, high-priority target.
---
## 4. Decision ladder
Walked through with the user 2026-05-26. Captured here so we don't re-litigate.
### L1 — drop-in `setInnerHtmlIfChanged` helper
- **Shape:** `WeakMap<Element, string>` cache; replace every `el.innerHTML = x` with `setInnerHtmlIfChanged(el, x)`.
- **Wins:** stops the no-change rewrites globally; zero behavior risk; ~30 call-site changes.
- **Misses:** still tears down DOM when content *does* differ (e.g. FPS row values change every tick); doesn't preserve focus/transition state inside a list.
- **Verdict:** floor, not ceiling. Worth doing for cells that don't get migrated to L3/Lit.
### L2 — lint guard
- **Shape:** pre-commit script greps `\.innerHTML\s*=` in `static/js/` outside an allowlist, fails the commit.
- **Wins:** keeps the discipline; cheap.
- **Misses:** only useful as a pair with L1+; bare guard with no helper makes contributors angry.
- **Verdict:** pair with whatever helper we land on.
### L3 — hand-rolled cell-component pattern
- **Shape:** `defineCell({ html, refs, mount, update, unmount })` + `reconcileList(host, items, binding)` + `setText/setClass/setAttr` mutators. ~150300 lines of runtime.
- **Wins:** correct by construction; no dependencies; explicit about what mutates; composes with existing customize panel / color picker.
- **Misses:** we own the abstraction — it grows over time as we need transitions, async data, focus, devtools, error boundaries. Death by a thousand features.
- **Verdict:** second-best. Strong contender if zero-deps is a hard constraint.
### Lit migration of polling modules — **recommended**
- **Shape:** convert each perf cell + each dashboard card cell to a Lit web component. Use `html\`<span>${value}</span>\`` tagged-template + targeted diff. ~5KB gzip added to bundle, no new build step (esbuild handles it).
- **Wins:** solves the bug class by design; maintained by Google + community; web-components-based so no framework lock-in; composes with vanilla DOM trivially; mental model is close to current template-string idiom; non-polling code can stay vanilla forever.
- **Misses:** introduces a dependency; contributors learn one more thing; rare edge cases (`@html`-equivalent exists and reintroduces the bug if misused).
- **Verdict:** best ceiling-to-cost ratio for a small team. Recommended.
### Full framework rewrite (React / Vue / Solid)
- **Verdict:** overkill. The bug class lives in polling paths; the rest of the app is fine. Spending the migration budget on rebuilding IconSelect / EntitySelect / modals / customize panel / graph editor — none of which are broken — is a bad trade.
---
## 5. Recommendation
**Lit for the polling-heavy modules.**
Migration plan:
### Phase 0 — spike (2-hour time-box)
- Convert `patches` cell to a Lit component, end to end.
- Verify it plays nicely with: color picker integration, customize panel layout reorder, `rerenderPerfGrid` reconciliation, `setPerfMode` toggle, hidden-by-env state, the spark tooltip handler.
- If any of those break in an unfixable way → pivot to L3.
- If they work → commit to the migration.
### Phase 1 — perf-charts cells
1. `patches` (already spiked)
2. `devices`
3. `fps` / `capture_fps` / `capture_fps_actual` (share a sparkline base class)
4. `cpu` / `ram` / `gpu` / `temp` (share `_sparkCardHtml` template family)
5. `network` / `device_latency` / `send_timing` / `errors`
Each is its own PR, dashboard stays working at every step. `renderPerfSection` becomes a registry of Lit components; `rerenderPerfGrid` becomes "reorder existing elements in the grid" (which it mostly already does).
### Phase 2 — dashboard card cells
6. Output target cards (running variant — biggest payoff, has live FPS + uptime + errors)
7. Output target cards (stopped variant)
8. Sync clock cards
9. Automation cards
10. Integration (HA / MQTT) cards
These get bigger wins from the migration because they have nested mutable state (FPS pill, errors cell, health dot, action button) that's currently rebuilt per poll via the `_updateRunningMetrics` path.
### Highest-impact: `entity-events.ts` tab reconciliation (sequence early)
`entity-events.ts` (§3.5) is the single highest-blast-radius site and is **not** on the dashboard — it re-renders the Targets / Integrations / Automations tabs on server push. Whether or not those tabs' cells become Lit components, the loader path (`loadTargetsTab` / `loadIntegrations` / `loadAutomations`) should switch from a full `innerHTML` rebuild to a **keyed list reconcile** (a Lit `repeat()` over the tab body). This preserves scroll / focus / open inline editors across pushes. If the goal is "biggest UX win first" rather than "lowest-risk first," sequence this ahead of Phase 2.
### Phase 3 — stopgap helper for the rest
Add `setInnerHtmlIfChanged` and apply to any remaining vanilla polling sites we don't plan to migrate. Add the L2 lint guard at this point — by now everything that polls is either Lit-managed or uses the helper.
### Out of scope (deliberately) — with one correction (2026-05-27)
- Targets tab, automations editor, integrations, scene presets — these render on-demand, **but they are ALSO re-rendered on server push** via `entity-events.ts` (see §3.5). The original claim that "the bug class doesn't bite them" was **wrong**: a pushed `server:entity_changed` does a full-tab `innerHTML` teardown. The *editor / on-demand views* can stay vanilla, but the **list/tab render that entity-events triggers needs reconciliation** (a keyed list diff) regardless of whether those cells become Lit components. Treat the entity-events reload path as **in-scope** — it is the highest-blast-radius Problem B site.
- Color strips editor, graph editor, settings — genuinely on-demand, no push re-render path, stay vanilla.
- Transport bar cells (CPU/Mem chip in the top bar) — read from the same perf payload, can be migrated opportunistically but not urgent.
---
## 6. Open questions to settle before committing
These came up during the discussion and weren't resolved:
1. **Bundle-size budget.** Is +5KB acceptable? Current bundle is 2.7MB so this is noise — but worth confirming there isn't a strict cap (e.g. for slow networks / Android Chaquopy embed).
2. **Contributor model.** If the project will grow to multiple contributors, Lit's smaller community vs React's is a recruiting tradeoff. Currently solo-ish, so probably moot.
3. **Android TV target.** Chaquopy embed serves the same bundle. Lit works fine in any modern browser — Android TV WebView is Chromium-based. Should be a no-op but verify in Phase 0 spike.
4. **Long-term framework intent.** If there's a chance we ever migrate to React/Vue/Solid for the rest of the app, doing Lit now is *not* lock-in (web components are standard), but it does add a second mental model. Probably fine; just naming the tradeoff.
5. **Customize panel.** The drag-reorder code in `dashboard-customize.ts` mutates `.dashboard-section` DOM directly. Lit components reorder cleanly via `moveBefore` / `insertBefore` since they're just elements, but the dnd library needs to treat them as opaque drag handles. Phase 0 spike should confirm.
---
## 7. Pointers
- Source files most relevant:
- `server/src/ledgrab/static/js/features/dashboard.ts`
- `server/src/ledgrab/static/js/features/perf-charts.ts`
- `server/src/ledgrab/static/js/features/dashboard-layout.ts` (cell ordering + visibility)
- `server/src/ledgrab/static/js/features/dashboard-customize.ts` (drag-reorder UI)
- `server/src/ledgrab/static/js/core/card-modes.ts` (mode toggle that hangs off section headers)
- `server/src/ledgrab/static/js/core/entity-events.ts` (push-driven tab reloads — §3.5, highest blast radius)
- `server/src/ledgrab/static/js/core/events-ws.ts` (WS → `server:*` CustomEvent dispatch)
- `server/src/ledgrab/static/js/features/game-integration.ts` (2 s event-feed list rebuild — §3.5)
- Most recent reconciliation commit: `f6486f9`.
- Related skill files in `~/.claude/skills/`: `frontend-patterns`, `documentation-lookup` (for Lit docs via Context7).
- Locale convention: `perf.*` for cross-card primitives, `dashboard.perf.*` for cell titles.
---
## 8. If this doc gets stale
If you read this and the perf cells are already Lit components — delete this file. If you read this and there's a new flicker / focus / transition bug nobody can explain — search for `\.innerHTML\s*=` in `static/js/features/` **and `static/js/core/`** (`entity-events` lives in core) and you've probably found it. For *state loss on a server event* (scroll jump, focus drop, an inline editor closing itself), look at the `server:*` listeners in `core/entity-events.ts` first.
+1 -1
View File
@@ -41,7 +41,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.7.0"
versionName = "0.8.0"
// ABI selection. Detect armeabi-v7a wheel presence and opt the
// ABI in only when the matching pydantic-core wheel is on disk —
+327
View File
@@ -0,0 +1,327 @@
"""Generate LedGrab app icon assets.
Concept: "Spectrum Aperture" — a rounded-square frame (the screen/display)
traced by a continuous RGB color-wheel stroke (the bias-light LED strip),
on a near-black canvas with a soft chromatic bloom behind it.
Outputs:
server/src/ledgrab/static/icons/icon-512.png (standard, opaque vignette bg)
server/src/ledgrab/static/icons/icon-192.png (downscale of 512)
server/src/ledgrab/static/icons/icon-512-maskable.png (safe-area padded, opaque)
server/src/ledgrab/static/icons/icon-tray.png (256, transparent bg, frame + glow)
server/src/ledgrab/static/icons/icon.ico (16/24/32/48/64/128/256)
Run from repo root:
py -3.13 build/generate_icon.py
"""
from __future__ import annotations
import colorsys
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
# ── Tunables ────────────────────────────────────────────────────────────
SUPERSAMPLE = 4 # render at 4x and downsample for crispness
BASE = 1024 # logical canvas size
HQ = BASE * SUPERSAMPLE # render canvas
BG_TOP = (12, 14, 22) # near-black, faint cool tint
BG_BOTTOM = (6, 7, 12) # darker at edges (vignette feel)
FRAME_INSET = 0.18 # margin from canvas edge to frame (fraction)
FRAME_RADIUS = 0.22 # corner radius (fraction of frame side)
FRAME_STROKE = 0.085 # stroke width (fraction of canvas)
BLOOM_OPACITY = 0.62 # outer bloom strength (01)
INNER_GLOW_OPACITY = 0.38 # inner chromatic reflection strength
# Hue rotation offset so red sits at the top
HUE_OFFSET = -90.0 # degrees (negative = counter-clockwise shift)
def lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def hue_to_rgb(hue_deg: float) -> tuple[int, int, int]:
"""Bright, slightly desaturated spectral color (LED-like)."""
h = (hue_deg % 360) / 360.0
r, g, b = colorsys.hls_to_rgb(h, 0.58, 0.92)
return int(r * 255), int(g * 255), int(b * 255)
def vignette_background(size: int) -> Image.Image:
"""Dark canvas with a soft radial vignette + faint scanline noise."""
img = Image.new("RGB", (size, size), BG_TOP)
px = img.load()
cx, cy = size / 2, size / 2
max_r = math.hypot(cx, cy)
for y in range(size):
for x in range(size):
d = math.hypot(x - cx, y - cy) / max_r
t = min(1.0, d**1.6)
px[x, y] = (
int(lerp(BG_TOP[0], BG_BOTTOM[0], t)),
int(lerp(BG_TOP[1], BG_BOTTOM[1], t)),
int(lerp(BG_TOP[2], BG_BOTTOM[2], t)),
)
return img
def draw_chromatic_bloom(size: int) -> Image.Image:
"""Soft, large chromatic glow behind the frame — the bias-light effect."""
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
cx, cy = size / 2, size / 2
radius = size * 0.36
blob_r = int(size * 0.30)
n_blobs = 24
for i in range(n_blobs):
a = i / n_blobs * 360.0
bx = cx + math.cos(math.radians(a - 90)) * radius
by = cy + math.sin(math.radians(a - 90)) * radius
r, g, b = hue_to_rgb(a + HUE_OFFSET)
alpha = int(255 * BLOOM_OPACITY * 0.55)
draw.ellipse(
(bx - blob_r, by - blob_r, bx + blob_r, by + blob_r),
fill=(r, g, b, alpha),
)
# Heavy blur → continuous, dreamy halo
layer = layer.filter(ImageFilter.GaussianBlur(radius=size * 0.10))
return layer
def rounded_rect_mask(size: int, inset: int, radius: int, stroke: int) -> Image.Image:
"""L-mode mask of a rounded-rect ring (the frame stroke region)."""
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
box_outer = (inset, inset, size - inset, size - inset)
box_inner = (
inset + stroke,
inset + stroke,
size - inset - stroke,
size - inset - stroke,
)
r_outer = radius
r_inner = max(0, radius - stroke)
draw.rounded_rectangle(box_outer, radius=r_outer, fill=255)
draw.rounded_rectangle(box_inner, radius=r_inner, fill=0)
return mask
def draw_spectrum_frame(size: int) -> Image.Image:
"""Draw the rounded-square frame stroke filled with a hue-rotation gradient.
Strategy: paint a full-canvas angular hue gradient (centered), then
clip it with the rounded-ring mask. This guarantees a continuous,
seam-free color flow around the entire frame.
"""
cx, cy = size / 2, size / 2
gradient = Image.new("RGB", (size, size), (0, 0, 0))
gpx = gradient.load()
for y in range(size):
dy = y - cy
for x in range(size):
dx = x - cx
ang = math.degrees(math.atan2(dy, dx)) + 90.0 # 0° = top
r, g, b = hue_to_rgb(ang + HUE_OFFSET)
gpx[x, y] = (r, g, b)
inset = int(size * FRAME_INSET)
frame_side = size - 2 * inset
stroke = int(size * FRAME_STROKE)
radius = int(frame_side * FRAME_RADIUS)
mask = rounded_rect_mask(size, inset, radius, stroke)
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
out.paste(gradient, (0, 0), mask)
return out
def draw_inner_screen(size: int) -> Image.Image:
"""Subtle dark rounded square inside the frame, with faint chromatic
inner reflection along the edges — like a screen catching ambient light."""
inset = int(size * FRAME_INSET)
stroke = int(size * FRAME_STROKE)
frame_side = size - 2 * inset
radius = int(frame_side * FRAME_RADIUS)
pad = int(stroke * 0.35)
box = (
inset + stroke + pad,
inset + stroke + pad,
size - inset - stroke - pad,
size - inset - stroke - pad,
)
r_inner = max(0, radius - stroke - pad)
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
# Dark fill, very slight cool tint
draw.rounded_rectangle(box, radius=r_inner, fill=(10, 12, 18, 255))
# Inner chromatic glow: same spectrum, very soft, clipped to the screen
bloom = draw_chromatic_bloom(size)
screen_mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(screen_mask).rounded_rectangle(box, radius=r_inner, fill=255)
bloom_alpha = bloom.split()[-1].point(lambda v: int(v * INNER_GLOW_OPACITY))
bloom.putalpha(bloom_alpha)
masked_bloom = Image.new("RGBA", (size, size), (0, 0, 0, 0))
masked_bloom.paste(bloom, (0, 0), screen_mask)
layer.alpha_composite(masked_bloom)
# Faint highlight glint top-left
glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
gdraw = ImageDraw.Draw(glint)
glint_box = (
box[0] + int(frame_side * 0.04),
box[1] + int(frame_side * 0.04),
box[0] + int(frame_side * 0.42),
box[1] + int(frame_side * 0.18),
)
gdraw.rounded_rectangle(glint_box, radius=int(frame_side * 0.05), fill=(255, 255, 255, 22))
glint = glint.filter(ImageFilter.GaussianBlur(radius=size * 0.012))
masked_glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
masked_glint.paste(glint, (0, 0), screen_mask)
layer.alpha_composite(masked_glint)
return layer
def add_outer_frame_glow(frame_rgba: Image.Image) -> Image.Image:
"""Take the spectrum frame and produce a blurred, brightened copy for glow."""
glow = frame_rgba.copy()
# Slightly inflate brightness for glow
r, g, b, a = glow.split()
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.85)))))
glow = glow.filter(ImageFilter.GaussianBlur(radius=glow.width * 0.025))
return glow
def render_tray(size: int) -> Image.Image:
"""Render a tray-optimised icon: transparent background, bolder frame,
tight outer glow. Designed to read clearly at 1632 px on top of any
taskbar color."""
hq = size * SUPERSAMPLE
# Pull the frame inward a touch and beef up the stroke so it reads at 16 px.
global FRAME_INSET, FRAME_STROKE
saved_inset, saved_stroke = FRAME_INSET, FRAME_STROKE
FRAME_INSET = 0.13
FRAME_STROKE = 0.115
try:
frame = draw_spectrum_frame(hq)
finally:
FRAME_INSET, FRAME_STROKE = saved_inset, saved_stroke
# Tight, bright glow that doesn't bleed past the tray cell.
glow = frame.copy()
r, g, b, a = glow.split()
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.95)))))
glow = glow.filter(ImageFilter.GaussianBlur(radius=hq * 0.012))
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
canvas.alpha_composite(glow)
canvas.alpha_composite(frame)
return canvas.resize((size, size), Image.LANCZOS)
def render(size: int, *, maskable: bool = False) -> Image.Image:
"""Render the full icon at the given size."""
hq = size * SUPERSAMPLE
if maskable:
# Maskable: pad inward so the entire icon survives a circular crop.
# We render the standard composition at 80% of canvas size, centered.
bg = Image.new("RGB", (hq, hq), BG_BOTTOM).convert("RGBA")
bg.paste(vignette_background(hq), (0, 0))
inner = render(size, maskable=False).resize((int(hq * 0.78), int(hq * 0.78)), Image.LANCZOS)
# Strip the bg from the inner render: composite the spectrum
# parts on top of our maskable background.
ox = (hq - inner.width) // 2
oy = (hq - inner.height) // 2
bg.alpha_composite(inner, (ox, oy))
return bg.resize((size, size), Image.LANCZOS)
bg = vignette_background(hq).convert("RGBA")
bloom = draw_chromatic_bloom(hq)
frame = draw_spectrum_frame(hq)
frame_glow = add_outer_frame_glow(frame)
inner_screen = draw_inner_screen(hq)
# Composite order: bg → bloom → frame_glow → inner_screen → frame
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
canvas.alpha_composite(bg)
canvas.alpha_composite(bloom)
canvas.alpha_composite(frame_glow)
canvas.alpha_composite(inner_screen)
canvas.alpha_composite(frame)
return canvas.resize((size, size), Image.LANCZOS)
def main() -> None:
repo_root = Path(__file__).resolve().parent.parent
targets = [
repo_root / "server" / "src" / "ledgrab" / "static" / "icons",
repo_root
/ "android"
/ "app"
/ "build"
/ "python"
/ "sources"
/ "debug"
/ "ledgrab"
/ "static"
/ "icons",
]
print("Rendering 1024 master...")
master = render(1024, maskable=False)
print("Rendering maskable 1024 master...")
maskable_master = render(1024, maskable=True)
print("Rendering tray 512 master (transparent bg)...")
tray_master = render_tray(512)
for icons_dir in targets:
if not icons_dir.exists():
print(f" skip (missing): {icons_dir}")
continue
out_512 = icons_dir / "icon-512.png"
out_192 = icons_dir / "icon-192.png"
out_mask = icons_dir / "icon-512-maskable.png"
out_tray = icons_dir / "icon-tray.png"
out_ico = icons_dir / "icon.ico"
master.resize((512, 512), Image.LANCZOS).save(out_512, "PNG", optimize=True)
master.resize((192, 192), Image.LANCZOS).save(out_192, "PNG", optimize=True)
maskable_master.resize((512, 512), Image.LANCZOS).save(out_mask, "PNG", optimize=True)
tray_master.save(out_tray, "PNG", optimize=True)
# Pre-resize each frame from the 1024 master for maximum crispness.
# Pass them via the `sizes` arg so Pillow embeds every variant.
ico_sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
# Use the tray (transparent-bg) variant for ICO frames so the file/
# taskbar icon doesn't show a dark tile against light backgrounds.
ico_source = tray_master.resize((256, 256), Image.LANCZOS)
ico_source.save(out_ico, format="ICO", sizes=ico_sizes)
print(f" wrote: {icons_dir}")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.7.0"
version = "0.8.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+1 -1
View File
@@ -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.0"
def _read_pyproject_version() -> str | None:
+4 -2
View File
@@ -49,7 +49,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:
@@ -154,8 +155,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),
)
+48 -16
View File
@@ -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()
@@ -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:
+89 -11
View File
@@ -1,10 +1,12 @@
"""Auto-backup engine — periodic SQLite snapshot backups."""
"""Auto-backup engine — periodic SQLite + assets snapshot backups."""
import asyncio
import 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(
{
+1
View File
@@ -283,6 +283,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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 35 KiB

@@ -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);
}
+8 -8
View File
@@ -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,7 +3,7 @@
* Supports creating, changing, and detaching connections via the graph editor.
*/
import { fetchWithAuth } from './api.ts';
import { apiPut } from './api-client.ts';
import {
streamsCache, colorStripSourcesCache, valueSourcesCache,
audioSourcesCache, outputTargetsCache, automationsCacheObj,
@@ -151,11 +151,7 @@ export async function updateConnection(targetId: string, targetKind: string, fie
const body = { [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;
@@ -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 = {};
File diff suppressed because it is too large Load Diff
@@ -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);
}
@@ -7,7 +7,8 @@ import {
_discoveryCache, set_discoveryCache,
csptCache,
} 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';
@@ -1036,20 +1037,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
@@ -1267,36 +1259,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 +1296,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 +1712,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;
@@ -6,7 +6,8 @@ import {
_deviceBrightnessCache, updateDeviceBrightness,
csptCache,
} 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';
@@ -363,19 +364,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 +378,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 +405,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 || [];
@@ -934,18 +915,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 +948,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 +962,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 +1039,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 +1113,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();
@@ -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');
}
}
File diff suppressed because it is too large Load Diff
@@ -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;
}
@@ -0,0 +1,62 @@
/**
* Automation shapes rule sets (`AutomationRule[]`) combined with
* AND/OR logic that activate a scene preset. `AutomationRule` is a wide
* optional-field shape keyed by `rule_type`; see audit finding H8 for the
* frontend rule-type registry that dispatches on it.
*/
export type RuleType =
| 'application' | 'time_of_day' | 'system_idle'
| 'display_state' | 'mqtt' | 'webhook' | 'startup'
| 'home_assistant' | 'http_poll';
export type HTTPPollOperator =
| 'equals' | 'not_equals' | 'contains' | 'regex'
| 'gt' | 'lt' | 'exists';
export interface AutomationRule {
rule_type: RuleType;
apps?: string[];
match_type?: string;
start_time?: string;
end_time?: string;
idle_minutes?: number;
when_idle?: boolean;
state?: string;
topic?: string;
payload?: string;
match_mode?: string;
token?: string;
/** home_assistant rule */
ha_source_id?: string;
entity_id?: string;
/** http_poll rule — references an HTTPValueSource. */
value_source_id?: string;
operator?: HTTPPollOperator;
value?: string;
}
export interface Automation {
id: string;
name: string;
enabled: boolean;
rule_logic: 'or' | 'and';
rules: AutomationRule[];
scene_preset_id?: string;
deactivation_mode: 'none' | 'revert' | 'fallback_scene';
deactivation_scene_preset_id?: string;
tags: string[];
webhook_url?: string;
is_active: boolean;
last_activated_at?: string;
last_deactivated_at?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface AutomationListResponse {
automations: Automation[];
count: number;
}
@@ -0,0 +1,181 @@
/**
* Color strip source (CSS) shapes the per-source-type field bag plus
* the supporting structures (gradient stops, composite layers, mapped
* zones, calibration). `ColorStripSource` is a wide optional-field shape
* because the backend stores all source types in one collection keyed by
* `source_type`.
*/
import type { BindableColor, BindableFloat } from './bindable.ts';
import type { KeyColorRectangle } from './pattern-template.ts';
import type { GameEventMapping } from './game-integration.ts';
export type CSSSourceType =
| 'picture' | 'picture_advanced' | 'single_color' | 'gradient'
| 'effect' | 'composite' | 'mapped'
| 'audio' | 'api_input' | 'notification' | 'daylight'
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
| 'game_event' | 'math_wave';
export interface ColorStop {
position: number;
color: number[];
color_right?: number[];
}
export interface CompositeLayer {
source_id: string;
blend_mode: string;
opacity: number;
enabled: boolean;
brightness_source_id?: string;
processing_template_id?: string;
}
export interface MappedZone {
source_id: string;
start: number;
end: number;
reverse: boolean;
}
export interface AnimationConfig {
enabled: boolean;
type: string;
speed: number;
}
export interface CalibrationLine {
picture_source_id: string;
edge: 'top' | 'right' | 'bottom' | 'left';
led_count: number;
span_start: number;
span_end: number;
reverse: boolean;
border_width: number;
}
export interface Calibration {
mode: 'simple' | 'advanced';
lines?: CalibrationLine[];
layout?: 'clockwise' | 'counterclockwise';
start_position?: 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right';
offset?: number;
leds_top?: number;
leds_right?: number;
leds_bottom?: number;
leds_left?: number;
span_top_start?: number;
span_top_end?: number;
span_right_start?: number;
span_right_end?: number;
span_bottom_start?: number;
span_bottom_end?: number;
span_left_start?: number;
span_left_end?: number;
skip_leds_start?: number;
skip_leds_end?: number;
border_width?: number;
}
export interface ColorStripSource {
id: string;
name: string;
source_type: CSSSourceType;
led_count: number;
description?: string;
tags: string[];
overlay_active: boolean;
clock_id?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
// Picture
picture_source_id?: string;
smoothing?: BindableFloat;
interpolation_mode?: string;
calibration?: Calibration;
// Static / Effect / Candlelight
color?: BindableColor;
// Gradient
stops?: ColorStop[];
// Effect
effect_type?: string;
palette?: string;
intensity?: BindableFloat;
scale?: BindableFloat;
mirror?: boolean;
// Composite
layers?: CompositeLayer[];
// Mapped
zones?: MappedZone[];
// Audio
visualization_mode?: string;
audio_source_id?: string;
sensitivity?: BindableFloat;
color_peak?: BindableColor;
// Animation
animation?: AnimationConfig;
speed?: BindableFloat;
// API Input
fallback_color?: BindableColor;
timeout?: BindableFloat;
interpolation?: string;
// Notification
notification_effect?: string;
duration_ms?: number;
default_color?: BindableColor | string;
app_colors?: Record<string, string>;
app_filter_mode?: string;
app_filter_list?: string[];
os_listener?: boolean;
sound_asset_id?: string | null;
sound_volume?: BindableFloat;
app_sounds?: Record<string, { sound_asset_id?: string | null; volume?: number }>;
// Daylight
use_real_time?: boolean;
latitude?: number;
longitude?: number;
// Candlelight
num_candles?: number;
wind_strength?: BindableFloat;
// Processed
input_source_id?: string;
processing_template_id?: string;
// Weather
weather_source_id?: string;
temperature_influence?: BindableFloat;
// Key Colors
rectangles?: KeyColorRectangle[];
brightness?: BindableFloat;
// Game Event
game_integration_id?: string;
idle_color?: BindableColor;
event_mappings?: GameEventMapping[];
// Math Wave
waves?: Array<{ waveform: string; frequency: number; amplitude: number; phase: number; offset: number }>;
gradient_id?: string;
}
export interface ColorStripSourceListResponse {
sources: ColorStripSource[];
count: number;
}
@@ -0,0 +1,66 @@
/**
* Device entity shapes physical/logical LED controllers and groups.
*
* Mirrors the backend `storage/device_store.py` dataclass and the
* `api/schemas/devices.py` Pydantic models. Field names use snake_case
* to match the JSON payloads.
*/
export type DeviceType =
| 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws'
| 'openrgb' | 'dmx' | 'ddp' | 'opc' | 'espnow' | 'hue' | 'yeelight' | 'wiz' | 'lifx' | 'govee'
| 'nanoleaf'
| 'ble' | 'usbhid' | 'spi'
| 'chroma' | 'gamesense' | 'group';
export interface Device {
id: string;
name: string;
url: string;
device_type: DeviceType;
led_count: number;
enabled: boolean;
baud_rate?: number;
auto_shutdown: boolean;
send_latency_ms: number;
rgbw: boolean;
zone_mode: string;
capabilities: string[];
tags: string[];
dmx_protocol: string;
dmx_start_universe: number;
dmx_start_channel: number;
ddp_port: number;
ddp_destination_id: number;
ddp_color_order: number;
opc_channel: number;
espnow_peer_mac: string;
espnow_channel: number;
hue_paired: boolean;
hue_entertainment_group_id: string;
yeelight_min_interval_ms: number;
wiz_min_interval_ms: number;
lifx_min_interval_ms: number;
govee_min_interval_ms: number;
nanoleaf_paired: boolean;
nanoleaf_min_interval_ms: number;
spi_speed_hz: number;
spi_led_type: string;
chroma_device_type: string;
gamesense_device_type: string;
default_css_processing_template_id: string;
group_device_ids: string[];
group_mode: string;
/** Optional id from the curated icon library (e.g. 'mouse', 'motherboard').
* Empty/missing no plate is rendered, head reverts to badge-only layout. */
icon?: string;
/** Optional CSS color override for the icon. Empty/missing inherits --ch. */
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface DeviceListResponse {
devices: Device[];
count: number;
}
@@ -0,0 +1,14 @@
/**
* Display shape a detected monitor as returned by
* `GET /api/v1/config/displays`.
*/
export interface Display {
index: number;
name: string;
width: number;
height: number;
x: number;
y: number;
is_primary: boolean;
}
@@ -0,0 +1,79 @@
/**
* Game integration shapes adapters (Chroma, GameSense, ), their
* eventeffect mappings, runtime status, and curated effect presets.
*/
export interface GameEventMapping {
event_type: string;
effect_type: string;
color: number[];
duration_ms: number;
intensity: number;
priority: number;
}
export interface GameIntegration {
id: string;
name: string;
adapter_type: string;
adapter_config: Record<string, any>;
event_mappings: GameEventMapping[];
enabled: boolean;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface GameIntegrationListResponse {
integrations: GameIntegration[];
count: number;
}
export interface GameAdapterConfigField {
name: string;
type: string;
label?: string;
default?: any;
required?: boolean;
hint?: string;
}
export interface GameAdapterInfo {
adapter_type: string;
display_name: string;
game_name: string;
supported_events: string[];
config_schema: GameAdapterConfigField[];
setup_instructions?: string;
supports_auto_setup?: boolean;
}
export interface GameAdapterListResponse {
adapters: GameAdapterInfo[];
}
export interface GameEventRecord {
timestamp: string;
event_type: string;
value?: number;
data?: Record<string, any>;
}
export interface GameIntegrationStatus {
integration_id: string;
connected: boolean;
last_event_at?: string;
event_count: number;
error?: string;
}
export interface EffectPreset {
key: string;
name: string;
description: string;
target_game_types: string[];
event_mappings: GameEventMapping[];
}
@@ -0,0 +1,39 @@
/**
* Home Assistant source shapes a HA connection plus its live
* connection-status projections used by the dashboard integration card.
*/
export interface HomeAssistantSource {
id: string;
name: string;
host: string;
use_ssl: boolean;
entity_filters: string[];
connected: boolean;
entity_count: number;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface HomeAssistantSourceListResponse {
sources: HomeAssistantSource[];
count: number;
}
export interface HomeAssistantConnectionStatus {
source_id: string;
name: string;
connected: boolean;
entity_count: number;
host?: string;
}
export interface HomeAssistantStatusResponse {
connections: HomeAssistantConnectionStatus[];
total_sources: number;
connected_count: number;
}
@@ -0,0 +1,63 @@
/**
* HTTP endpoint shapes.
*
* A connection definition only (URL + auth + headers + timeout).
* No polling cadence is configured on the endpoint itself
* HTTPValueSource owns interval_s and references the endpoint.
*/
export type HTTPMethod = 'GET' | 'HEAD';
export interface HTTPEndpoint {
id: string;
name: string;
url: string;
method: HTTPMethod;
/** Server NEVER returns the token; this flag indicates one is stored. */
auth_token_set: boolean;
headers: Record<string, string>;
timeout_s: number;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface HTTPEndpointListResponse {
endpoints: HTTPEndpoint[];
count: number;
}
/** Wire payload for `POST /http/endpoints` / `PUT /http/endpoints/{id}`.
* All fields optional the route validates required-on-create separately. */
export interface HTTPEndpointWritePayload {
name?: string;
url?: string;
method?: HTTPMethod;
/** Plaintext token. PUT distinguishes None=keep / ""=clear; omit the field to keep. */
auth_token?: string;
headers?: Record<string, string>;
timeout_s?: number;
description?: string;
tags?: string[];
icon?: string;
icon_color?: string;
}
export interface HTTPTestRequest {
url: string;
method: HTTPMethod;
auth_token: string;
headers: Record<string, string>;
timeout_s: number;
}
export interface HTTPTestResponse {
success: boolean;
status_code?: number;
body_preview?: string;
body_json?: unknown;
error?: string;
}
@@ -0,0 +1,40 @@
/**
* MQTT source shapes a broker connection plus its live connection
* status. Backs Zigbee2MQTT light targets and MQTT automation rules.
*/
export interface MQTTSource {
id: string;
name: string;
broker_host: string;
broker_port: number;
username: string;
password_set: boolean;
client_id: string;
base_topic: string;
connected: boolean;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface MQTTSourceListResponse {
sources: MQTTSource[];
count: number;
}
export interface MQTTConnectionStatus {
source_id: string;
name: string;
connected: boolean;
broker: string;
}
export interface MQTTStatusResponse {
connections: MQTTConnectionStatus[];
total_sources: number;
connected_count: number;
}
@@ -0,0 +1,94 @@
/**
* Output target shapes the discriminated union over `target_type`
* (`led` | `ha_light` | `z2m_light`). Each target binds a colour source
* to a physical/logical output.
*/
import type { BindableFloat } from './bindable.ts';
export type TargetType = 'led' | 'ha_light' | 'z2m_light';
export interface HALightMapping {
entity_id: string;
led_start: number;
led_end: number;
brightness_scale: BindableFloat;
}
export interface Z2MLightMapping {
friendly_name: string;
led_start: number;
led_end: number;
brightness_scale: BindableFloat;
}
interface OutputTargetBase {
id: string;
name: string;
target_type: TargetType;
description?: string;
tags: string[];
/** Optional id from the curated icon library. Empty/missing
* for LED targets, the card inherits the device's icon; for
* HA-light targets, no plate is rendered. */
icon?: string;
/** Optional CSS color override for the icon. Empty/missing
* inherits the device color (LED targets) or --ch (others). */
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface LedOutputTarget extends OutputTargetBase {
target_type: 'led';
device_id: string;
color_strip_source_id: string;
brightness?: BindableFloat;
fps?: BindableFloat;
keepalive_interval: number;
state_check_interval: number;
min_brightness_threshold?: BindableFloat;
adaptive_fps: boolean;
protocol: string;
}
export type HALightSourceKind = 'css' | 'color_vs';
export interface HALightOutputTarget extends OutputTargetBase {
target_type: 'ha_light';
ha_source_id: string;
/** Which colour source feeds the lights: a CSS (`'css'`) or a colour-returning value source (`'color_vs'`). */
source_kind: HALightSourceKind;
color_strip_source_id: string;
/** Used when `source_kind === 'color_vs'`. References a value source whose `return_type === 'color'`. */
color_value_source_id?: string;
brightness?: BindableFloat;
ha_light_mappings?: HALightMapping[];
update_rate?: BindableFloat;
transition?: BindableFloat;
color_tolerance?: BindableFloat;
min_brightness_threshold?: BindableFloat;
}
export interface Z2MLightOutputTarget extends OutputTargetBase {
target_type: 'z2m_light';
mqtt_source_id: string;
source_kind: HALightSourceKind;
color_strip_source_id: string;
color_value_source_id?: string;
brightness?: BindableFloat;
z2m_light_mappings?: Z2MLightMapping[];
base_topic: string;
update_rate?: BindableFloat;
transition?: BindableFloat;
color_tolerance?: BindableFloat;
min_brightness_threshold?: BindableFloat;
stop_action?: 'none' | 'turn_off';
}
export type OutputTarget = LedOutputTarget | HALightOutputTarget | Z2MLightOutputTarget;
export interface OutputTargetListResponse {
targets: OutputTarget[];
count: number;
}
@@ -0,0 +1,29 @@
/**
* Pattern template shapes named collections of key-colour rectangles
* reused across key-colour CSS sources.
*/
export interface KeyColorRectangle {
name: string;
x: number;
y: number;
width: number;
height: number;
}
export interface PatternTemplate {
id: string;
name: string;
rectangles: KeyColorRectangle[];
tags: string[];
description?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface PatternTemplateListResponse {
templates: PatternTemplate[];
count: number;
}
@@ -0,0 +1,60 @@
/**
* Picture source shapes the discriminated union over `stream_type`
* (`raw` | `processed` | `static_image` | `video`). These feed the
* picture-based CSS sources and calibration.
*/
export type PictureSourceType = 'raw' | 'processed' | 'static_image' | 'video';
interface PictureSourceBase {
id: string;
name: string;
stream_type: PictureSourceType;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface RawPictureSource extends PictureSourceBase {
stream_type: 'raw';
display_index: number;
capture_template_id: string;
target_fps: number;
}
export interface ProcessedPictureSource extends PictureSourceBase {
stream_type: 'processed';
source_stream_id: string;
postprocessing_template_id: string;
}
export interface StaticImagePictureSource extends PictureSourceBase {
stream_type: 'static_image';
image_asset_id?: string;
}
export interface VideoPictureSource extends PictureSourceBase {
stream_type: 'video';
video_asset_id?: string;
loop: boolean;
playback_speed: number;
start_time?: number;
end_time?: number;
resolution_limit?: number;
clock_id?: string;
target_fps: number;
}
export type PictureSource =
| RawPictureSource
| ProcessedPictureSource
| StaticImagePictureSource
| VideoPictureSource;
export interface PictureSourceListResponse {
streams: PictureSource[];
count: number;
}
@@ -0,0 +1,34 @@
/**
* Scene preset shapes a named snapshot of which targets run with which
* colour source / brightness / fps, applied as a group.
*/
import type { BindableFloat } from './bindable.ts';
export interface TargetSnapshot {
id?: string;
target_id: string;
running: boolean;
color_strip_source_id: string;
brightness?: BindableFloat;
fps: number;
}
export interface ScenePreset {
id: string;
name: string;
description: string;
color?: string;
targets: TargetSnapshot[];
order: number;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface ScenePresetListResponse {
presets: ScenePreset[];
count: number;
}
@@ -0,0 +1,23 @@
/**
* Sync clock shapes shared time bases that animated sources subscribe
* to so multiple effects stay phase-aligned.
*/
export interface SyncClock {
id: string;
name: string;
speed: number;
description?: string;
tags: string[];
is_running: boolean;
elapsed_time: number;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface SyncClockListResponse {
clocks: SyncClock[];
count: number;
}
@@ -0,0 +1,91 @@
/**
* Processing template shapes capture engines, post-processing /
* colour-strip filter chains, and audio engines plus the filter and
* engine definition shapes returned by the discovery endpoints.
*/
export interface FilterInstance {
filter_id: string;
options: Record<string, any>;
}
export interface CaptureTemplate {
id: string;
name: string;
engine_type: string;
engine_config: Record<string, any>;
tags: string[];
description?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface PostprocessingTemplate {
id: string;
name: string;
filters: FilterInstance[];
tags: string[];
description?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface ColorStripProcessingTemplate {
id: string;
name: string;
filters: FilterInstance[];
tags: string[];
description?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface AudioTemplate {
id: string;
name: string;
engine_type: string;
engine_config: Record<string, any>;
tags: string[];
description?: string;
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
// ── Filter Definition (from /filters endpoint) ────────────────
export interface FilterOptionDef {
type: string;
default?: any;
min?: number;
max?: number;
step?: number;
choices?: string[];
label?: string;
}
export interface FilterDef {
id: string;
name: string;
description?: string;
category?: string;
options: Record<string, FilterOptionDef>;
}
// ── Engine Info (from /capture-engines, /audio-engines) ───────
export interface EngineInfo {
type: string;
name: string;
available: boolean;
has_own_displays?: boolean;
default_config?: Record<string, any>;
config_choices?: Record<string, string[]>;
}
@@ -0,0 +1,198 @@
/**
* Value source shapes the discriminated union over `source_type`.
* Each variant returns either a `float` or a `color`; the union drives
* the value-source editor and the bindable-binding pickers.
*/
export type ValueSourceType =
| 'static' | 'animated' | 'audio'
| 'adaptive_time' | 'adaptive_scene' | 'daylight'
| 'static_color' | 'animated_color' | 'adaptive_time_color'
| 'ha_entity' | 'gradient_map' | 'css_extract'
| 'system_metrics' | 'game_event' | 'http';
export interface SchedulePoint {
time: string;
value: number;
}
export interface ColorSchedulePoint {
time: string;
color: number[];
}
interface ValueSourceBase {
id: string;
name: string;
source_type: ValueSourceType;
return_type: 'float' | 'color';
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface StaticValueSource extends ValueSourceBase {
source_type: 'static';
return_type: 'float';
value: number;
}
export interface AnimatedValueSource extends ValueSourceBase {
source_type: 'animated';
return_type: 'float';
waveform: string;
speed: number;
min_value: number;
max_value: number;
}
export interface AudioValueSource extends ValueSourceBase {
source_type: 'audio';
return_type: 'float';
audio_source_id: string;
mode: string;
sensitivity: number;
smoothing: number;
min_value: number;
max_value: number;
auto_gain: boolean;
}
export interface AdaptiveTimeValueSource extends ValueSourceBase {
source_type: 'adaptive_time';
return_type: 'float';
schedule: SchedulePoint[];
min_value: number;
max_value: number;
}
export interface AdaptiveSceneValueSource extends ValueSourceBase {
source_type: 'adaptive_scene';
return_type: 'float';
picture_source_id: string;
scene_behavior: string;
sensitivity: number;
smoothing: number;
min_value: number;
max_value: number;
}
export interface DaylightValueSource extends ValueSourceBase {
source_type: 'daylight';
return_type: 'float';
speed: number;
use_real_time: boolean;
latitude: number;
longitude: number;
min_value: number;
max_value: number;
}
export interface StaticColorValueSource extends ValueSourceBase {
source_type: 'static_color';
return_type: 'color';
color: number[];
}
export interface AnimatedColorValueSource extends ValueSourceBase {
source_type: 'animated_color';
return_type: 'color';
colors: number[][];
speed: number;
easing: string;
clock_id?: string;
}
export interface AdaptiveTimeColorValueSource extends ValueSourceBase {
source_type: 'adaptive_time_color';
return_type: 'color';
schedule: ColorSchedulePoint[];
}
export interface HAEntityValueSource extends ValueSourceBase {
source_type: 'ha_entity';
return_type: 'float';
ha_source_id: string;
entity_id: string;
attribute: string;
min_ha_value: number;
max_ha_value: number;
smoothing: number;
}
export interface GradientMapValueSource extends ValueSourceBase {
source_type: 'gradient_map';
return_type: 'color';
value_source_id: string;
gradient_id: string;
easing: string;
}
export interface CSSExtractValueSource extends ValueSourceBase {
source_type: 'css_extract';
return_type: 'color';
color_strip_source_id: string;
led_start: number;
led_end: number;
}
export interface SystemMetricsValueSource extends ValueSourceBase {
source_type: 'system_metrics';
return_type: 'float';
metric: string;
min_value: number;
max_value: number;
max_rate: number;
disk_path: string;
sensor_label: string;
poll_interval: number;
smoothing: number;
}
export interface GameEventValueSource extends ValueSourceBase {
source_type: 'game_event';
return_type: 'float';
game_integration_id: string;
event_type: string;
min_game_value: number;
max_game_value: number;
smoothing: number;
default_value: number;
timeout: number;
}
export interface HTTPValueSource extends ValueSourceBase {
source_type: 'http';
return_type: 'float';
http_endpoint_id: string;
json_path: string;
interval_s: number;
min_value: number;
max_value: number;
smoothing: number;
}
export type ValueSource =
| StaticValueSource
| AnimatedValueSource
| AudioValueSource
| AdaptiveTimeValueSource
| AdaptiveSceneValueSource
| DaylightValueSource
| StaticColorValueSource
| AnimatedColorValueSource
| AdaptiveTimeColorValueSource
| HAEntityValueSource
| GradientMapValueSource
| CSSExtractValueSource
| SystemMetricsValueSource
| GameEventValueSource
| HTTPValueSource;
export interface ValueSourceListResponse {
sources: ValueSource[];
count: number;
}
@@ -0,0 +1,25 @@
/**
* Weather source shapes a provider connection (+ location) that
* weather-driven CSS sources read temperature / conditions from.
*/
export interface WeatherSource {
id: string;
name: string;
provider: string;
provider_config: Record<string, any>;
latitude: number;
longitude: number;
update_interval: number;
description?: string;
tags: string[];
icon?: string;
icon_color?: string;
created_at: string;
updated_at: string;
}
export interface WeatherSourceListResponse {
sources: WeatherSource[];
count: number;
}
+13
View File
@@ -125,6 +125,8 @@
"templates.error.engines": "Failed to load engines",
"templates.error.required": "Please fill in all required fields",
"templates.error.delete": "Failed to delete template",
"templates.error.save_failed": "Failed to save template",
"templates.error.load_failed": "Failed to load template",
"templates.test.title": "Test Capture",
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
"templates.test.display": "Display:",
@@ -1283,6 +1285,10 @@
"automations.deleted": "Automation deleted",
"automations.error.name_required": "Name is required",
"automations.error.clone_failed": "Failed to clone automation",
"automations.error.load_failed": "Failed to load automation",
"automations.error.save_failed": "Failed to save automation",
"automations.error.delete_failed": "Failed to delete automation",
"automations.error.toggle_failed": "Failed to toggle automation",
"scenes.title": "Scenes",
"scenes.add": "Capture Scene",
"scenes.edit": "Edit Scene",
@@ -1824,6 +1830,8 @@
"audio_template.error.engines": "Failed to load audio engines",
"audio_template.error.required": "Please fill in all required fields",
"audio_template.error.delete": "Failed to delete audio template",
"audio_template.error.save_failed": "Failed to save audio template",
"audio_template.error.load_failed": "Failed to load audio template",
"streams.group.value": "Value Sources",
"streams.group.sync": "Sync Clocks",
"streams.group.gradients": "Gradients",
@@ -1843,6 +1851,7 @@
"gradient.error.name_required": "Name is required",
"gradient.error.min_stops": "At least 2 color stops are required",
"gradient.error.delete_failed": "Failed to delete gradient",
"gradient.error.save_failed": "Failed to save gradient",
"gradient.create_name": "New gradient name:",
"gradient.edit_name": "Rename gradient:",
"gradient.confirm_delete": "Delete gradient \"{name}\"?",
@@ -2208,6 +2217,7 @@
"device.error.update": "Failed to update device",
"device.error.save": "Failed to save settings",
"device.error.clone_failed": "Failed to clone device",
"device.error.load_failed": "Failed to load device",
"device_discovery.error.fill_all_fields": "Please fill in all fields",
"device_discovery.added": "Device added successfully",
"device_discovery.error.add_failed": "Failed to add device",
@@ -2249,6 +2259,7 @@
"target.error.stop_failed": "Failed to stop target",
"target.error.clone_failed": "Failed to clone target",
"target.error.delete_failed": "Failed to delete target",
"target.error.load_failed": "Failed to load target",
"targets.stop_all.button": "Stop All",
"targets.stop_all.none_running": "No targets are currently running",
"targets.stop_all.stopped": "Stopped {count} target(s)",
@@ -2263,6 +2274,7 @@
"pattern.error.clone_failed": "Failed to clone pattern template",
"pattern.error.delete_failed": "Failed to delete pattern template",
"pattern.error.capture_bg_failed": "Failed to capture background",
"pattern.error.save_failed": "Failed to save pattern template",
"stream.error.clone_picture_failed": "Failed to clone picture source",
"stream.error.clone_capture_failed": "Failed to clone capture template",
"stream.error.clone_pp_failed": "Failed to clone postprocessing template",
@@ -2953,6 +2965,7 @@
"audio_processing.error.load": "Error loading audio processing template",
"audio_processing.error.delete": "Error deleting audio processing template",
"audio_processing.error.clone_failed": "Failed to clone audio processing template",
"audio_processing.error.save_failed": "Failed to save audio processing template",
"audio_processing.filter_count": "Filter count",
"audio_processing.filters_label": "filters",
"streams.group.audio_processing": "Audio Processing",
+14
View File
@@ -180,6 +180,8 @@
"templates.error.engines": "Не удалось загрузить движки",
"templates.error.required": "Пожалуйста, заполните все обязательные поля",
"templates.error.delete": "Не удалось удалить шаблон",
"templates.error.save_failed": "Не удалось сохранить шаблон",
"templates.error.load_failed": "Не удалось загрузить шаблон",
"templates.test.title": "Тест Захвата",
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
"templates.test.display": "Дисплей:",
@@ -1317,6 +1319,10 @@
"automations.deleted": "Автоматизация удалена",
"automations.error.name_required": "Введите название",
"automations.error.clone_failed": "Не удалось клонировать автоматизацию",
"automations.error.load_failed": "Не удалось загрузить автоматизацию",
"automations.error.save_failed": "Не удалось сохранить автоматизацию",
"automations.error.delete_failed": "Не удалось удалить автоматизацию",
"automations.error.toggle_failed": "Не удалось переключить автоматизацию",
"scenes.title": "Сцены",
"scenes.add": "Захватить сцену",
"scenes.edit": "Редактировать сцену",
@@ -1789,6 +1795,10 @@
"audio_template.error.engines": "Не удалось загрузить аудиодвижки",
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
"audio_template.error.save_failed": "Не удалось сохранить аудиошаблон",
"audio_template.error.load_failed": "Не удалось загрузить аудиошаблон",
"gradient.error.save_failed": "Не удалось сохранить градиент",
"gradient.error.delete_failed": "Не удалось удалить градиент",
"streams.group.value": "Источники значений",
"streams.group.sync": "Часы синхронизации",
"tree.group.picture": "Источники изображений",
@@ -2067,6 +2077,7 @@
"device.error.update": "Не удалось обновить устройство",
"device.error.save": "Не удалось сохранить настройки",
"device.error.clone_failed": "Не удалось клонировать устройство",
"device.error.load_failed": "Не удалось загрузить устройство",
"device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля",
"device_discovery.added": "Устройство успешно добавлено",
"device_discovery.error.add_failed": "Не удалось добавить устройство",
@@ -2108,6 +2119,7 @@
"target.error.stop_failed": "Не удалось остановить цель",
"target.error.clone_failed": "Не удалось клонировать цель",
"target.error.delete_failed": "Не удалось удалить цель",
"target.error.load_failed": "Не удалось загрузить цель",
"targets.stop_all.button": "Остановить все",
"targets.stop_all.none_running": "Нет запущенных целей",
"targets.stop_all.stopped": "Остановлено целей: {count}",
@@ -2122,6 +2134,7 @@
"pattern.error.clone_failed": "Не удалось клонировать шаблон узоров",
"pattern.error.delete_failed": "Не удалось удалить шаблон узоров",
"pattern.error.capture_bg_failed": "Не удалось захватить фон",
"pattern.error.save_failed": "Не удалось сохранить шаблон узоров",
"stream.error.clone_picture_failed": "Не удалось клонировать источник изображения",
"stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата",
"stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки",
@@ -2634,6 +2647,7 @@
"audio_processing.error.load": "Ошибка загрузки шаблона обработки звука",
"audio_processing.error.delete": "Ошибка удаления шаблона обработки звука",
"audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука",
"audio_processing.error.save_failed": "Не удалось сохранить шаблон обработки звука",
"audio_processing.filter_count": "Количество фильтров",
"audio_processing.filters_label": "фильтров",
"streams.group.audio_processing": "Обработка звука",
+14
View File
@@ -178,6 +178,8 @@
"templates.error.engines": "加载引擎失败",
"templates.error.required": "请填写所有必填项",
"templates.error.delete": "删除模板失败",
"templates.error.save_failed": "保存模板失败",
"templates.error.load_failed": "加载模板失败",
"templates.test.title": "测试采集",
"templates.test.description": "保存前测试此模板,查看采集预览和性能指标。",
"templates.test.display": "显示器:",
@@ -1313,6 +1315,10 @@
"automations.deleted": "自动化已删除",
"automations.error.name_required": "名称为必填项",
"automations.error.clone_failed": "克隆自动化失败",
"automations.error.load_failed": "加载自动化失败",
"automations.error.save_failed": "保存自动化失败",
"automations.error.delete_failed": "删除自动化失败",
"automations.error.toggle_failed": "切换自动化失败",
"scenes.title": "场景",
"scenes.add": "捕获场景",
"scenes.edit": "编辑场景",
@@ -1785,6 +1791,10 @@
"audio_template.error.engines": "加载音频引擎失败",
"audio_template.error.required": "请填写所有必填项",
"audio_template.error.delete": "删除音频模板失败",
"audio_template.error.save_failed": "保存音频模板失败",
"audio_template.error.load_failed": "加载音频模板失败",
"gradient.error.save_failed": "保存渐变失败",
"gradient.error.delete_failed": "删除渐变失败",
"streams.group.value": "值源",
"streams.group.sync": "同步时钟",
"tree.group.picture": "图片源",
@@ -2063,6 +2073,7 @@
"device.error.update": "更新设备失败",
"device.error.save": "保存设置失败",
"device.error.clone_failed": "克隆设备失败",
"device.error.load_failed": "加载设备失败",
"device_discovery.error.fill_all_fields": "请填写所有字段",
"device_discovery.added": "设备添加成功",
"device_discovery.error.add_failed": "添加设备失败",
@@ -2104,6 +2115,7 @@
"target.error.stop_failed": "停止目标失败",
"target.error.clone_failed": "克隆目标失败",
"target.error.delete_failed": "删除目标失败",
"target.error.load_failed": "加载目标失败",
"targets.stop_all.button": "全部停止",
"targets.stop_all.none_running": "当前没有运行中的目标",
"targets.stop_all.stopped": "已停止 {count} 个目标",
@@ -2118,6 +2130,7 @@
"pattern.error.clone_failed": "克隆图案模板失败",
"pattern.error.delete_failed": "删除图案模板失败",
"pattern.error.capture_bg_failed": "捕获背景失败",
"pattern.error.save_failed": "保存图案模板失败",
"stream.error.clone_picture_failed": "克隆图片源失败",
"stream.error.clone_capture_failed": "克隆捕获模板失败",
"stream.error.clone_pp_failed": "克隆后处理模板失败",
@@ -2628,6 +2641,7 @@
"audio_processing.error.load": "加载音频处理模板时出错",
"audio_processing.error.delete": "删除音频处理模板时出错",
"audio_processing.error.clone_failed": "克隆音频处理模板失败",
"audio_processing.error.save_failed": "保存音频处理模板失败",
"audio_processing.filter_count": "过滤器数量",
"audio_processing.filters_label": "个过滤器",
"streams.group.audio_processing": "音频处理",
+17 -13
View File
@@ -101,16 +101,15 @@ class _WNDCLASS(ctypes.Structure):
]
class _MSG(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND),
("message", wintypes.UINT),
("wParam", wintypes.WPARAM),
("lParam", wintypes.LPARAM),
("time", wintypes.DWORD),
("pt_x", wintypes.LONG),
("pt_y", wintypes.LONG),
]
# Use the stdlib wintypes.MSG (rather than a project-local _MSG) so the
# POINTER(MSG) type is shared with any other module that binds
# user32.GetMessageW.argtypes — argtypes is a global on the cached DLL
# handle, and two modules binding it with different POINTER classes for
# the same C function fight each other (last writer wins, the other one's
# byref() then trips Python 3.13's strict argtype check). PlatformDetector's
# display-power monitor binds with POINTER(wintypes.MSG); aligning here
# means whichever loads last produces the same binding.
_MSG = wintypes.MSG
def _bind_winapi() -> None:
@@ -133,18 +132,23 @@ def _bind_winapi() -> None:
]
user32.DefWindowProcW.restype = LRESULT
# Pin the MSG pointer type once and reuse the same class object on all
# three prototypes — Python 3.13 ctypes rejects mismatched POINTER(_MSG)
# caches across argtypes, even though they look identical.
LPMSG = ctypes.POINTER(_MSG)
user32.GetMessageW.argtypes = [
ctypes.POINTER(_MSG),
LPMSG,
wintypes.HWND,
wintypes.UINT,
wintypes.UINT,
]
user32.GetMessageW.restype = wintypes.BOOL
user32.TranslateMessage.argtypes = [ctypes.POINTER(_MSG)]
user32.TranslateMessage.argtypes = [LPMSG]
user32.TranslateMessage.restype = wintypes.BOOL
user32.DispatchMessageW.argtypes = [ctypes.POINTER(_MSG)]
user32.DispatchMessageW.argtypes = [LPMSG]
user32.DispatchMessageW.restype = LRESULT
user32.PostMessageW.argtypes = [
@@ -0,0 +1,210 @@
"""Regression coverage for :class:`AutoBackupEngine` ZIP format.
Pins behavior added when auto-backups switched from raw ``.db`` snapshots to
``.zip`` archives (DB + assets), and the partial-write hardening that goes
with it (stage at ``<name>.partial`` then ``os.replace`` so a crash mid-write
never leaves a corrupt file masquerading as a valid backup).
"""
import zipfile
from pathlib import Path
from unittest.mock import patch
import pytest
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.database import Database
@pytest.fixture()
def engine_with_assets(tmp_path):
"""Engine wired to a small DB + assets dir with two sample files."""
db_path = tmp_path / "ledgrab.db"
assets_dir = tmp_path / "assets"
assets_dir.mkdir()
(assets_dir / "alert.wav").write_bytes(b"WAV_PAYLOAD_1")
(assets_dir / "ping.wav").write_bytes(b"WAV_PAYLOAD_2")
db = Database(db_path)
db.upsert("devices", "dev_1", "Living Room", {"id": "dev_1", "type": "mock"})
backup_dir = tmp_path / "backups"
engine = AutoBackupEngine(backup_dir=backup_dir, db=db, assets_dir=assets_dir)
yield engine
db.close()
def _only_backup(backup_dir: Path) -> Path:
"""Return the single .zip backup in ``backup_dir``; assert exactly one."""
zips = sorted(backup_dir.glob("*.zip"))
assert len(zips) == 1, f"expected exactly one .zip, found {[p.name for p in zips]}"
return zips[0]
def test_backup_bundles_db_and_assets(engine_with_assets):
"""Happy path: ZIP contains ``ledgrab.db`` plus every assets file."""
engine_with_assets._perform_backup_sync()
backup = _only_backup(engine_with_assets._backup_dir)
with zipfile.ZipFile(backup) as zf:
names = sorted(zf.namelist())
assert names == ["assets/alert.wav", "assets/ping.wav", "ledgrab.db"]
def test_backup_preserves_asset_bytes(engine_with_assets):
"""The asset binary inside the ZIP matches the source byte-for-byte."""
engine_with_assets._perform_backup_sync()
backup = _only_backup(engine_with_assets._backup_dir)
with zipfile.ZipFile(backup) as zf:
assert zf.read("assets/alert.wav") == b"WAV_PAYLOAD_1"
assert zf.read("assets/ping.wav") == b"WAV_PAYLOAD_2"
def test_backup_no_partial_file_left_on_success(engine_with_assets):
"""After a successful backup, the staging ``.partial`` is renamed away."""
engine_with_assets._perform_backup_sync()
leftovers = list(engine_with_assets._backup_dir.glob("*.partial"))
assert leftovers == []
def test_backup_skips_assets_when_dir_none(tmp_path):
"""``assets_dir=None`` produces a DB-only ZIP, no error."""
db_path = tmp_path / "ledgrab.db"
db = Database(db_path)
db.upsert("devices", "dev_1", "A", {"id": "dev_1"})
backup_dir = tmp_path / "backups"
try:
engine = AutoBackupEngine(backup_dir=backup_dir, db=db, assets_dir=None)
engine._perform_backup_sync()
backup = _only_backup(backup_dir)
with zipfile.ZipFile(backup) as zf:
assert zf.namelist() == ["ledgrab.db"]
finally:
db.close()
def test_backup_skips_assets_when_dir_missing(tmp_path):
"""A non-existent ``assets_dir`` is silently skipped, not an error."""
db_path = tmp_path / "ledgrab.db"
db = Database(db_path)
backup_dir = tmp_path / "backups"
try:
engine = AutoBackupEngine(
backup_dir=backup_dir, db=db, assets_dir=tmp_path / "does-not-exist"
)
engine._perform_backup_sync()
backup = _only_backup(backup_dir)
with zipfile.ZipFile(backup) as zf:
assert zf.namelist() == ["ledgrab.db"]
finally:
db.close()
def test_backup_failure_leaves_no_zip_or_partial(engine_with_assets):
"""When ``db.backup_to`` raises, neither the final .zip nor the .partial
survives. The exception propagates so the caller can log it.
"""
with patch.object(engine_with_assets._db, "backup_to", side_effect=RuntimeError("boom")):
with pytest.raises(RuntimeError, match="boom"):
engine_with_assets._perform_backup_sync()
assert list(engine_with_assets._backup_dir.glob("*.zip")) == []
assert list(engine_with_assets._backup_dir.glob("*.partial")) == []
def test_backup_cleans_stale_partial_from_previous_crash(engine_with_assets):
"""A leftover ``.partial`` from a prior crash is swept on the next run."""
stale = engine_with_assets._backup_dir
stale.mkdir(parents=True, exist_ok=True)
(stale / "ledgrab-backup-2025-01-01T000000.zip.partial").write_bytes(b"corrupt")
engine_with_assets._perform_backup_sync()
assert list(stale.glob("*.partial")) == []
# The successful backup is also present.
assert len(list(stale.glob("*.zip"))) == 1
def test_backup_skips_symlinks_in_assets_dir(tmp_path):
"""Symlinked files in ``assets_dir`` are not bundled (security guard)."""
db_path = tmp_path / "ledgrab.db"
assets_dir = tmp_path / "assets"
assets_dir.mkdir()
(assets_dir / "real.wav").write_bytes(b"REAL")
# Some test environments (e.g. unprivileged Windows) can't create
# symlinks; skip rather than fail spuriously when that's the case.
secret_target = tmp_path / "secret.bin"
secret_target.write_bytes(b"PRIVATE")
link_path = assets_dir / "linked.wav"
try:
link_path.symlink_to(secret_target)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported in this environment")
db = Database(db_path)
try:
backup_dir = tmp_path / "backups"
engine = AutoBackupEngine(backup_dir=backup_dir, db=db, assets_dir=assets_dir)
engine._perform_backup_sync()
backup = _only_backup(backup_dir)
with zipfile.ZipFile(backup) as zf:
names = zf.namelist()
assert "assets/real.wav" in names
assert "assets/linked.wav" not in names
finally:
db.close()
def test_list_backups_unions_legacy_db_and_new_zip(engine_with_assets):
"""``list_backups`` returns both legacy ``.db`` and current ``.zip`` files
so users can still restore from auto-backups written by older versions.
"""
backup_dir = engine_with_assets._backup_dir
backup_dir.mkdir(parents=True, exist_ok=True)
(backup_dir / "ledgrab-backup-2024-12-31T000000.db").write_bytes(b"legacy")
engine_with_assets._perform_backup_sync()
names = {b["filename"] for b in engine_with_assets.list_backups()}
assert any(n.endswith(".db") for n in names)
assert any(n.endswith(".zip") for n in names)
def test_prune_honors_max_across_formats(tmp_path):
"""``_prune_old_backups`` enforces ``max_backups`` across both extensions."""
db_path = tmp_path / "ledgrab.db"
db = Database(db_path)
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Two legacy .db files (older), two .zip files (newer) — max=3 should
# prune the single oldest .db and keep the rest.
import time
(backup_dir / "ledgrab-backup-2024-01-01T000000.db").write_bytes(b"a")
time.sleep(0.02)
(backup_dir / "ledgrab-backup-2024-02-01T000000.db").write_bytes(b"b")
time.sleep(0.02)
(backup_dir / "ledgrab-backup-2024-03-01T000000.zip").write_bytes(b"c")
time.sleep(0.02)
(backup_dir / "ledgrab-backup-2024-04-01T000000.zip").write_bytes(b"d")
try:
engine = AutoBackupEngine(backup_dir=backup_dir, db=db, assets_dir=None)
engine._settings["max_backups"] = 3
engine._prune_old_backups()
remaining = sorted(p.name for p in engine._iter_backup_files())
assert remaining == [
"ledgrab-backup-2024-02-01T000000.db",
"ledgrab-backup-2024-03-01T000000.zip",
"ledgrab-backup-2024-04-01T000000.zip",
]
finally:
db.close()