Compare commits

..

2 Commits

Author SHA1 Message Date
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
58 changed files with 2426 additions and 2364 deletions
+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
@@ -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();
@@ -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 {
@@ -313,20 +314,17 @@ export function ensureNotifSoundEntitySelect() {
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 +353,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": "音频处理",