Compare commits

...

3 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
alexei.dolgolyov ef1f9eade2 feat(android): production-readiness pass — security, perf, compat, UI/UX
Multi-axis lift to ship-quality after a full review:

Security
- ApiKeyManager: per-install random API key, persisted via SharedPreferences
  with synchronous first-write; threaded into uvicorn via the
  LEDGRAB_AUTH__API_KEYS env var; embedded in QR as a URL fragment (#k=)
  so it never appears in HTTP requests or server logs; frontend reads
  location.hash on first visit and strips it via history.replaceState
- Root.runAsRoot(argv: Array<String>) overload with POSIX shell-quoting to
  eliminate the shell-injection footgun (= excluded from unquoted-safe set)
- UsbSerialBridge: ContextCompat.RECEIVER_NOT_EXPORTED + intent.package
  check in the broadcast receiver for defence-in-depth across API levels
- Release builds refuse to silently fall back to debug keystore; require
  ANDROID_KEYSTORE_* env vars or explicit
  ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1
- Crash log retention capped at 10 entries
- Fatal-error stack trace hidden behind a toggle on the error screen

Performance
- ScreenCapture / RootScreenrecord reuse a single RGBA ByteArray per
  pipeline instead of allocating per frame — eliminates ~15 MB/s GC churn
  at 30 fps on low-end TV boxes
- Frame pacer switched from System.currentTimeMillis() + integer division
  (~30.3 fps drift) to SystemClock.elapsedRealtimeNanos with a catch-up
  accumulator
- ScreenCapture computes capture dimensions from source aspect ratio so
  non-16:9 displays don't get squashed
- RootScreenrecord input pump backs off 5 ms when MediaCodec is starved,
  ending a tight spin that burned a CPU core on decoder stalls
- QR cached by URL — onResume from background no longer rebuilds the
  560×560 bitmap each time
- ApiKey commit() pre-warmed off Main on app startup

Compatibility
- compileSdk / targetSdk bumped to 35 (Play Store requirement)
- armeabi-v7a build path added to build script + conditionally included
  in gradle splits when the matching wheel is present in android/wheels/
- Foreground service type declared as mediaProjection|specialUse with
  PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale; promotion via
  ServiceCompat.startForeground with the correct type per mode
- NetworkUtils picks Ethernet > Wi-Fi > VPN > cellular instead of just
  activeNetwork — fixes wrong-URL on TV boxes with both Ethernet + Wi-Fi
- enableOnBackInvokedCallback=true for Android 15 predictive-back
- Splash screen API via androidx.core:core-splashscreen — hides Chaquopy
  stdlib unpack delay on cold first launch

UI / UX
- All previously hardcoded English strings (root prompt, permission
  denial, fatal-error screen, notification text) now localised across
  en/ru/zh
- Monochrome notification icon (was a colored launcher → gray blob in
  status bar)
- 320×180 TV banner (was the square launcher → squashed on Leanback row)
- ViewStub-based running panel (deferred inflation)
- ObjectAnimator pulse on the Running status dot for liveness feedback
- "Starting…" button state while root is being probed
- Autostart checkbox hidden entirely on unrooted devices
- "No network" status when getLocalIpAddress returns null
- QR fallback hint text
- Animator cancelled in onStop to avoid leaking view hierarchy

Lifecycle hardening (from review)
- RootScreenrecord: processLock serialises EOF respawn vs concurrent
  stop() to prevent orphaned screenrecord processes
- CaptureService.restartRootPipeline: publish-before-start under
  @Synchronized to close the orphan window during watchdog restarts
- ScreenCapture.MediaProjection.Callback.onStop just flips
  running=false instead of calling stop() (which self-joined
  captureThread and hung 500 ms)
- updateUI early-returns when lateinit not initialised (fatal-error path)
- Watchdog give-up bound fixed (>= instead of >, was allowing 4 attempts)

server/android_entry.py accepts an optional api_key, sets
LEDGRAB_AUTH__API_KEYS={"android":<key>} as JSON before any LedGrab
import, logs a clear error if pydantic-settings parsing doesn't land
the value back in config (defensive guard against future settings
behaviour drift).

server/static/js/app.ts: bootstrap reads #k= from location.hash,
persists to localStorage, then strips via history.replaceState.

Two independent code-review passes; 147 relevant server tests still
pass; TypeScript and ruff clean.
2026-05-26 12:52:14 +03:00
84 changed files with 3683 additions and 2688 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
+43 -10
View File
@@ -30,23 +30,39 @@ val ledgrabVersionCode: Int = run {
android {
namespace = "com.ledgrab.android"
compileSdk = 34
// SDK 35 (Android 15) — required for Play Store from Aug 2025 onward.
compileSdk = 35
defaultConfig {
applicationId = "com.ledgrab.android"
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
targetSdk = 34
targetSdk = 35
// Derived from git commit count (or ANDROID_VERSION_CODE env var
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.7.0"
// ABI selection. Detect armeabi-v7a wheel presence and opt the
// ABI in only when the matching pydantic-core wheel is on disk —
// otherwise Chaquopy would fail the build searching for it. The
// build script (build-scripts/build-pydantic-core.sh) is the
// source of truth for which ABIs we *can* ship.
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
val ledgrabAbis = buildList {
add("arm64-v8a")
add("x86_64")
add("x86")
if (v7Wheel) add("armeabi-v7a")
}
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
// emulators), x86 (legacy emulators). Wheels in android/wheels/
// must be kept in sync — see build-scripts/build-pydantic-core.sh.
abiFilters += listOf("arm64-v8a", "x86_64", "x86")
// arm64-v8a is the primary target (real TV hardware).
// x86_64/x86 cover emulators.
// armeabi-v7a is opt-in: many pre-2018 Mecool/X96/H96 TV boxes
// still ship 32-bit ARMv7 — when a wheel exists in wheels/ we
// automatically include the ABI in builds.
abiFilters += ledgrabAbis
}
}
@@ -54,9 +70,12 @@ android {
// Each split contains only one native ABI's shared libraries + wheels.
splits {
abi {
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
isEnable = true
reset()
include("arm64-v8a", "x86_64", "x86")
if (v7Wheel) include("armeabi-v7a")
isUniversalApk = true // also produce a fat APK for sideloading
}
}
@@ -96,10 +115,21 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = if (hasCiSigning) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
// Refuse to silently sign release APKs with the debug
// keystore — that's how a debug-signed release accidentally
// ships. CI must provide all four signing env vars. If a
// local "release" build is genuinely intended for testing,
// set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to opt out.
val allowDebugSigned =
System.getenv("ANDROID_ALLOW_DEBUG_SIGNED_RELEASE") == "1"
signingConfig = when {
hasCiSigning -> signingConfigs.getByName("release")
allowDebugSigned -> signingConfigs.getByName("debug")
else -> throw GradleException(
"Release builds require signing env vars " +
"(ANDROID_KEYSTORE_PATH/PASSWORD, ANDROID_KEY_ALIAS/PASSWORD). " +
"Set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to force a debug-signed release."
)
}
}
}
@@ -175,6 +205,9 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// SplashScreen API — keeps a friendly logo on screen while Chaquopy
// unpacks the Python stdlib on first launch (can take 1-3s).
implementation("androidx.core:core-splashscreen:1.0.1")
// QR code generation for displaying server URL on TV
implementation("com.google.zxing:core:3.5.3")
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
+27 -7
View File
@@ -26,9 +26,15 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- MediaProjection requires a foreground service -->
<!-- Foreground service permissions.
FOREGROUND_SERVICE_MEDIA_PROJECTION: required on API 34+ for the
MediaProjection capture path.
FOREGROUND_SERVICE_SPECIAL_USE: required on API 34+ for the root
screenrecord capture path (it doesn't use MediaProjection).
Both are declared because the service may run in either mode. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -60,27 +66,41 @@
<application
android:name=".LedGrabApp"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:banner="@drawable/ic_launcher"
android:banner="@drawable/banner_tv"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.LedGrab">
<!-- TV launcher activity -->
<!-- TV launcher activity. Boots through the SplashScreen theme so
the (sometimes multi-second) Chaquopy stdlib unpack doesn't
show as a black screen on first launch. -->
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:theme="@style/Theme.LedGrab.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Foreground service for screen capture + Python server -->
<!-- Foreground service for screen capture + Python server.
Declares BOTH mediaProjection AND specialUse: only one is
active at a time but Android needs to see the union of
possible types up-front so it doesn't kill the service when
we promote it with a different type at runtime.
FOREGROUND_SERVICE_TYPE_SPECIAL_USE on API 34+ requires the
PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale below. -->
<service
android:name=".CaptureService"
android:foregroundServiceType="mediaProjection"
android:exported="false" />
android:foregroundServiceType="mediaProjection|specialUse"
android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Root-mode screen capture for ambient LED sync. Uses /system/bin/screenrecord on rooted devices to avoid MediaProjection's persistent capture indicator overlay, which is required for the always-on ambient-lighting use case." />
</service>
<!-- Autostart — fires on device boot (and package replace).
On rooted devices, launches CaptureService directly so capture
@@ -0,0 +1,98 @@
package com.ledgrab.android
import android.content.Context
import android.util.Log
import java.security.SecureRandom
/**
* Persists the per-install API key for the embedded FastAPI server.
*
* The server's auth gate ([ledgrab.api.auth]) requires a Bearer token
* for any non-loopback request when ``auth.api_keys`` is configured.
* Without a key, LAN clients (phone, laptop) get 401 — which is the
* server's secure default but breaks the QR-scan workflow.
*
* This class generates one key per install (random 32-byte → 64-char
* hex), persists it to SharedPreferences, and exposes it to:
* - [PythonBridge] which sets ``LEDGRAB_AUTH__API_KEYS=android:<key>``
* before uvicorn starts.
* - [MainActivity] which embeds the key as a URL fragment
* (``http://ip:port/#k=<key>``) in the QR. Fragments are never sent
* to the server in HTTP requests, so the key doesn't appear in
* access logs.
*/
class ApiKeyManager(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// Once we've materialised a key in this process, cache it so
// subsequent reads don't hit prefs and don't risk re-checking
// length under contention.
@Volatile private var cached: String? = null
private val lock = Any()
/** Persistent random API key, generated lazily on first access. */
val apiKey: String
get() = getOrCreateKey()
/** Force a new key. Useful if a user thinks the QR was photographed. */
fun rotate(): String {
synchronized(lock) {
val next = generateKey()
// apply() is fine for rotation — by definition the user
// initiated this and will see the new QR; the worst case
// on crash is they need to re-rotate.
prefs.edit().putString(KEY_API_KEY, next).apply()
cached = next
Log.i(TAG, "Rotated API key")
return next
}
}
private fun getOrCreateKey(): String {
cached?.let { return it }
synchronized(lock) {
// Double-checked under the lock.
cached?.let { return it }
val existing = prefs.getString(KEY_API_KEY, null)
if (existing != null && existing.length >= MIN_KEY_LENGTH) {
cached = existing
return existing
}
val generated = generateKey()
// commit() (synchronous disk write) on the FIRST write so
// the key is durable before MainActivity encodes it into a
// QR. If the process is killed between QR display and the
// async write landing, the user's phone would scan a key
// the server never learned about. Subsequent rotates can
// safely use apply().
prefs.edit().putString(KEY_API_KEY, generated).commit()
cached = generated
Log.i(TAG, "Generated new API key (length=${generated.length})")
return generated
}
}
private fun generateKey(): String {
val bytes = ByteArray(KEY_BYTES)
SecureRandom().nextBytes(bytes)
// Hex-encode so the key survives copy/paste, URL fragments, env
// vars, and YAML config without escaping concerns. Mask to 0xff
// first — Kotlin's Byte is signed, and `%02x` on a negative
// Byte sign-extends to an 8-char hex string ("ffffffff" instead
// of "ff"), which would produce an invalid key.
return bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
companion object {
private const val TAG = "ApiKeyManager"
private const val PREFS_NAME = "ledgrab_auth"
private const val KEY_API_KEY = "api_key"
private const val KEY_BYTES = 32
private const val MIN_KEY_LENGTH = 32
/** Label used as the LEDGRAB_AUTH__API_KEYS map key. */
const val LABEL = "android"
}
}
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
@@ -15,6 +16,7 @@ import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -26,7 +28,13 @@ import kotlinx.coroutines.launch
/**
* Foreground service that runs the Python LedGrab server and captures
* the screen via MediaProjection.
* the screen via MediaProjection or root screenrecord.
*
* On Android 14+ the foreground-service "type" must match the work
* being done. We promote the service with the correct type (mediaProjection
* for the consent path, specialUse for the root path) instead of
* declaring a single fixed type in the manifest — the manifest now
* declares the *union* so promotion at runtime is permitted.
*/
class CaptureService : Service() {
@@ -92,15 +100,33 @@ class CaptureService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// CRITICAL: startForeground must be called IMMEDIATELY —
// before any other work, especially before getMediaProjection().
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
// CRITICAL: startForeground must be called IMMEDIATELY — before
// any other work, especially before getMediaProjection(). The
// service type must match the work; pass it explicitly via
// ServiceCompat so we stay compatible back to API 24.
val localIp = NetworkUtils.getLocalIpAddress(this) ?: ""
val url = "http://$localIp:$SERVER_PORT"
try {
startForeground(NOTIFICATION_ID, buildNotification(url))
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
if (useRoot) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
} else {
0
}
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
buildNotification(url),
type,
)
} catch (e: Exception) {
// Most common cause: missing foregroundServiceType permission
// or denied POST_NOTIFICATIONS on API 34+.
// or denied POST_NOTIFICATIONS on API 33+.
Log.e(TAG, "startForeground failed — service cannot run", e)
stopSelf()
return START_NOT_STICKY
@@ -109,8 +135,6 @@ class CaptureService : Service() {
// otherwise `isRunning=true` sticks forever when startForeground throws.
isRunning = true
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
if (intent == null && !useRoot) {
// MediaProjection mode can't recover from a redelivery —
// the consent token in the original intent is single-use.
@@ -140,10 +164,13 @@ class CaptureService : Service() {
return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
}
private fun apiKey(): String? =
(application as? LedGrabApp)?.apiKeyManager?.apiKey
private fun startRootCapture(url: String) {
val newBridge = PythonBridge(this).also { b ->
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
b.startServer(SERVER_PORT, apiKey())
}
bridge = newBridge
@@ -167,12 +194,21 @@ class CaptureService : Service() {
* Replace the active root pipeline with a fresh instance, reusing
* the existing Python bridge (no server restart). Returns true if
* the new pipeline launched, false otherwise.
*
* Synchronized so a concurrent onDestroy() either (a) sees the old
* instance and stops it then null-out, or (b) sees the new instance
* and stops it. There is no window where a fresh instance can be
* orphaned with no one holding a reference to it.
*/
@Synchronized
private fun restartRootPipeline(): Boolean {
val currentBridge = bridge ?: return false
val old = rootCapture
rootCapture = null
runCatching { old?.stop() }
// Tear down the old instance first so we don't run two
// screenrecord processes simultaneously fighting for the GPU.
rootCapture?.let { old ->
rootCapture = null
runCatching { old.stop() }
}
val next = RootScreenrecord(
bridge = currentBridge,
@@ -180,11 +216,21 @@ class CaptureService : Service() {
height = CAPTURE_HEIGHT,
fps = CAPTURE_FPS,
)
// Publish BEFORE start() — if onDestroy fires after this
// assignment but before start() completes, the field is non-null
// and onDestroy will stop() it properly. start() is idempotent
// enough (running=true, then resource construction) that being
// raced by stop() at most produces a brief partial-init that
// the next stop() call cleans up.
rootCapture = next
if (!next.start()) {
Log.e(TAG, "Root capture failed to restart")
// start() already called stop() on itself on the failure
// path — but null out the field so the watchdog/onDestroy
// don't try to stop it again.
rootCapture = null
return false
}
rootCapture = next
return true
}
@@ -212,7 +258,7 @@ class CaptureService : Service() {
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
"restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS",
)
if (restartAttempts > WATCHDOG_MAX_RESTARTS) {
if (restartAttempts >= WATCHDOG_MAX_RESTARTS) {
Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts")
stopSelf()
return@launch
@@ -263,7 +309,6 @@ class CaptureService : Service() {
val bounds = windowMetrics.bounds
widthPixels = bounds.width()
heightPixels = bounds.height()
// densityDpi is still needed for VirtualDisplay; read from resources.
densityDpi = resources.displayMetrics.densityDpi
}
} else {
@@ -276,7 +321,7 @@ class CaptureService : Service() {
val newBridge = PythonBridge(this).also { b ->
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
b.startServer(SERVER_PORT, apiKey())
}
bridge = newBridge
@@ -323,10 +368,10 @@ class CaptureService : Service() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"LedGrab Screen Capture",
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Shows while LedGrab is capturing the screen"
description = getString(R.string.notification_channel_description)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
@@ -343,9 +388,14 @@ class CaptureService : Service() {
PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("LedGrab Running")
.setContentText("Web UI: $url")
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text, url))
// ic_notification is a monochrome 24dp vector — status-bar
// icons must be white-on-transparent or they render as a
// gray blob on Android 5+.
.setSmallIcon(R.drawable.ic_notification)
.setColor(0xFF64FFDA.toInt())
.setColorized(true)
.setContentIntent(tapIntent)
.setOngoing(true)
.build()
@@ -26,9 +26,13 @@ class LedGrabApp : Application() {
var initError: Throwable? = null
private set
/** Lazily-initialized API-key manager (see [ApiKeyManager]). */
val apiKeyManager: ApiKeyManager by lazy { ApiKeyManager(this) }
override fun onCreate() {
super.onCreate()
installCrashLogger()
pruneOldCrashLogs()
try {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
@@ -47,6 +51,15 @@ class LedGrabApp : Application() {
// Bind application context for the BLE bridge so Python can
// scan and connect to BLE LED controllers.
BleBridge.init(this)
// Pre-warm the API key on a background thread. First-launch
// generation does a SharedPreferences.commit() (synchronous
// disk write — 10-50 ms on slow TV-box flash), which would
// hit the Main thread otherwise when MainActivity / CaptureService
// reads it. Doing it here makes subsequent reads memory-only.
Thread({
runCatching { apiKeyManager.apiKey }
}, "ledgrab-apikey-warmup").apply { isDaemon = true }.start()
}
/**
@@ -77,7 +90,24 @@ class LedGrabApp : Application() {
}
}
/**
* Keep only the most recent [MAX_CRASH_LOGS] crash files so a
* long-lived install doesn't slowly fill its private storage with
* historical traces. Cheap on every launch — listFiles is O(n)
* but n is tiny by construction.
*/
private fun pruneOldCrashLogs() {
val logs = filesDir.listFiles { f ->
f.isFile && f.name.startsWith("crash-") && f.name.endsWith(".log")
} ?: return
if (logs.size <= MAX_CRASH_LOGS) return
logs.sortedByDescending { it.lastModified() }
.drop(MAX_CRASH_LOGS)
.forEach { runCatching { it.delete() } }
}
companion object {
private const val TAG = "LedGrabApp"
private const val MAX_CRASH_LOGS = 10
}
}
@@ -1,7 +1,10 @@
package com.ledgrab.android
import android.Manifest
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
@@ -13,12 +16,16 @@ import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.view.View
import android.view.ViewStub
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Button
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.app.Activity
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.CoroutineScope
@@ -46,25 +53,47 @@ class MainActivity : Activity() {
private const val SERVER_PORT = 8080
private const val REQUEST_MEDIA_PROJECTION = 1001
private const val REQUEST_POST_NOTIFICATIONS = 1002
private const val QR_SIZE_PX = 560
}
// Stopped-state views (always inflated).
private lateinit var stoppedPanel: View
private lateinit var runningPanel: View
private lateinit var statusText: TextView
private lateinit var urlText: TextView
private lateinit var qrImage: ImageView
private lateinit var toggleButton: Button
private lateinit var stopButtonRunning: Button
private lateinit var versionText: TextView
private lateinit var autostartCheck: CheckBox
private lateinit var autostartPrefs: AutostartPrefs
// Running-state views (lazy-inflated via ViewStub).
private lateinit var runningPanelStub: ViewStub
private var runningPanel: View? = null
private var urlText: TextView? = null
private var qrImage: ImageView? = null
private var stopButtonRunning: Button? = null
private var statusDot: View? = null
private var statusDotAnimator: ObjectAnimator? = null
// Cache of the most recently rendered QR (and the URL it encodes).
// updateUI() runs on every onResume (HDMI-CEC wakes, app switches,
// overlay dismissal, etc.). Rebuilding the 560×560 bitmap each time
// is wasteful — usually the IP and key are unchanged. Cache and
// short-circuit when the URL matches.
private var cachedQrUrl: String? = null
private var cachedQrBitmap: Bitmap? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Install the splash screen BEFORE super.onCreate so the system
// keeps it on screen until our first frame is ready. This hides
// the Chaquopy stdlib unpack delay on cold first launch.
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
// Surface fatal Python init errors instead of crashing.
val initError = (application as? LedGrabApp)?.initError
if (initError != null) {
// Tell the splash screen to dismiss immediately — we're
// about to render an error screen, not the main UI.
splashScreen.setKeepOnScreenCondition { false }
showFatalErrorScreen(initError)
return
}
@@ -72,39 +101,62 @@ class MainActivity : Activity() {
setContentView(R.layout.activity_main)
stoppedPanel = findViewById(R.id.stopped_panel)
runningPanel = findViewById(R.id.running_panel)
runningPanelStub = findViewById(R.id.running_panel_stub)
statusText = findViewById(R.id.status_text)
urlText = findViewById(R.id.url_text)
qrImage = findViewById(R.id.qr_image)
toggleButton = findViewById(R.id.toggle_button)
stopButtonRunning = findViewById(R.id.stop_button_running)
versionText = findViewById(R.id.version_text)
autostartCheck = findViewById(R.id.autostart_check)
val versionName = packageManager
.getPackageInfo(packageName, 0).versionName
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
autostartPrefs = AutostartPrefs(this)
autostartCheck.isChecked = autostartPrefs.isEnabled
// Autostart only takes effect on rooted devices — grey it out
// on unrooted hardware so users don't expect magic. Cheap probe
// (file-existence only, no process spawn).
if (!Root.looksRooted()) {
autostartCheck.isEnabled = false
autostartCheck.text = getString(R.string.autostart_unavailable)
}
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
autostartPrefs.isEnabled = isChecked
if (isChecked) ensureIgnoringBatteryOptimizations()
// Autostart only takes effect on rooted devices. Hide the
// checkbox entirely on unrooted hardware instead of showing a
// disabled-but-visible control, which reads as broken UI from
// across the room.
if (Root.looksRooted()) {
autostartCheck.visibility = View.VISIBLE
autostartCheck.isChecked = autostartPrefs.isEnabled
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
autostartPrefs.isEnabled = isChecked
if (isChecked) ensureIgnoringBatteryOptimizations()
}
} else {
autostartCheck.visibility = View.GONE
}
toggleButton.setOnClickListener { startCapture() }
stopButtonRunning.setOnClickListener { stopCaptureService() }
updateUI()
}
override fun onDestroy() {
stopStatusDotPulse()
uiScope.cancel()
super.onDestroy()
}
override fun onStop() {
// ObjectAnimator retains a hard reference to the dot View. On
// backgrounded TV apps onDestroy may never fire, so cancel here
// to avoid leaking the entire view hierarchy through an
// INFINITE-repeat animator.
stopStatusDotPulse()
super.onStop()
}
override fun onResume() {
super.onResume()
// Restart the pulse if we returned to the foreground while the
// service is still running. The running panel's view may have
// been recreated; ensureRunningPanelInflated already keys off
// the field reference.
if (CaptureService.isRunning && ::stoppedPanel.isInitialized) {
updateUI()
}
}
/**
* Decide whether to go through the MediaProjection consent flow or
* jump straight into root capture. Root check is fast but may block
@@ -112,20 +164,19 @@ class MainActivity : Activity() {
* on the UI thread is acceptable because we're responding to a
* button press and we want to block until the user answers.
*/
override fun onDestroy() {
uiScope.cancel()
super.onDestroy()
}
private fun startCapture() {
// `su -c id` can block for seconds while Magisk shows its grant
// dialog; running it on the Main thread caused ANRs.
// dialog; running it on the Main thread caused ANRs. Render an
// explicit "starting" state so the button doesn't look frozen.
val originalText = toggleButton.text
toggleButton.isEnabled = false
statusText.text = "Checking root access…"
toggleButton.text = getString(R.string.btn_starting)
statusText.text = getString(R.string.status_checking_root)
uiScope.launch(Dispatchers.IO) {
val rooted = Root.requestGrant()
withContext(Dispatchers.Main) {
toggleButton.isEnabled = true
toggleButton.text = originalText
statusText.text = ""
if (rooted) {
Log.i(TAG, "Root available — skipping MediaProjection consent")
@@ -156,7 +207,7 @@ class MainActivity : Activity() {
if (resultCode == RESULT_OK && data != null) {
startCaptureService(resultCode, data)
} else {
statusText.text = "Permission denied — screen capture requires authorization"
statusText.text = getString(R.string.status_permission_denied)
Log.w(TAG, "MediaProjection permission denied")
}
}
@@ -174,42 +225,130 @@ class MainActivity : Activity() {
updateUI()
}
private fun updateUI() {
if (CaptureService.isRunning) {
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val url = "http://$localIp:$SERVER_PORT"
private fun ensureRunningPanelInflated(): View {
runningPanel?.let { return it }
val view = runningPanelStub.inflate()
urlText = view.findViewById(R.id.url_text)
qrImage = view.findViewById(R.id.qr_image)
stopButtonRunning = view.findViewById(R.id.stop_button_running)
statusDot = view.findViewById(R.id.status_dot)
stopButtonRunning?.setOnClickListener { stopCaptureService() }
runningPanel = view
return view
}
urlText.text = url
qrImage.setImageBitmap(null)
// Build the bitmap pixels off the Main thread — encode + 313k
// setPixel calls were noticeably janky on slow TV boxes.
uiScope.launch(Dispatchers.Default) {
val bitmap = generateQrCode(url)
withContext(Dispatchers.Main) {
if (CaptureService.isRunning && urlText.text == url) {
qrImage.setImageBitmap(bitmap)
private fun updateUI() {
// Fatal-init-error path took over setContentView and the
// lateinit view fields are unassigned. Guard so any future
// caller (Resume, broadcast receiver, etc.) doesn't NPE.
if (!::stoppedPanel.isInitialized) return
if (CaptureService.isRunning) {
val running = ensureRunningPanelInflated()
val localIp = NetworkUtils.getLocalIpAddress(this)
if (localIp == null) {
// No network — show the no-network state inside the
// stopped panel and keep capture stopped. The service
// is alive (capture works on loopback) but the URL/QR
// are useless without a routable address.
statusText.text = getString(R.string.status_no_network)
stoppedPanel.visibility = View.VISIBLE
versionText.visibility = View.VISIBLE
running.visibility = View.GONE
toggleButton.requestFocus()
return
}
val displayUrl = "http://$localIp:$SERVER_PORT"
val qrUrl = qrUrlFor(displayUrl)
urlText?.text = displayUrl
val cachedForUrl = cachedQrBitmap?.takeIf { cachedQrUrl == qrUrl }
if (cachedForUrl != null) {
qrImage?.setImageBitmap(cachedForUrl)
} else {
qrImage?.setImageBitmap(null)
// Build the bitmap pixels off the Main thread — encode + 313k
// setPixel calls were noticeably janky on slow TV boxes.
uiScope.launch(Dispatchers.Default) {
val bitmap = generateQrCode(qrUrl)
withContext(Dispatchers.Main) {
if (CaptureService.isRunning && urlText?.text == displayUrl) {
cachedQrUrl = qrUrl
cachedQrBitmap = bitmap
qrImage?.setImageBitmap(bitmap)
}
}
}
}
stoppedPanel.visibility = View.GONE
versionText.visibility = View.GONE
runningPanel.visibility = View.VISIBLE
stopButtonRunning.requestFocus()
running.visibility = View.VISIBLE
stopButtonRunning?.requestFocus()
startStatusDotPulse()
} else {
urlText.text = ""
qrImage.setImageBitmap(null)
stopStatusDotPulse()
urlText?.text = ""
qrImage?.setImageBitmap(null)
// Drop the cached bitmap so a Start → IP change → Start
// sequence rebuilds the QR for the new address.
cachedQrUrl = null
cachedQrBitmap = null
runningPanel.visibility = View.GONE
runningPanel?.visibility = View.GONE
stoppedPanel.visibility = View.VISIBLE
versionText.visibility = View.VISIBLE
toggleButton.requestFocus()
}
}
/**
* Build the URL we encode into the QR. Embeds the API key as a
* URL fragment (``#k=<token>``) so:
* - The token never appears in HTTP requests (fragments aren't
* sent over the wire) — no access-log leak.
* - The frontend can read [location.hash] on first visit and
* persist the key to localStorage (see static/js/app.ts).
* - The visible URL chip stays short and human-readable.
*
* The chip text in [updateUI] intentionally uses the *base* URL
* (without the fragment) so a human reading the URL out loud
* doesn't have to dictate 64 hex chars; only the QR carries the
* key. Do not collapse these into a single string — that would
* leak the key onto the screen.
*/
private fun qrUrlFor(base: String): String {
val key = (application as? LedGrabApp)?.apiKeyManager?.apiKey
return if (key.isNullOrBlank()) base else "$base/#k=$key"
}
private fun startStatusDotPulse() {
val dot = statusDot ?: return
if (statusDotAnimator?.isStarted == true) return
val animator = ObjectAnimator.ofFloat(dot, "alpha", 1f, 0.35f).apply {
duration = 900
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.REVERSE
interpolator = AccelerateDecelerateInterpolator()
}
animator.start()
statusDotAnimator = animator
}
private fun stopStatusDotPulse() {
statusDotAnimator?.cancel()
statusDotAnimator = null
statusDot?.alpha = 1f
}
private fun generateQrCode(text: String): Bitmap {
val size = 560
val size = QR_SIZE_PX
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
// ALPHA_8 = 1 byte/px instead of 2 (RGB_565) or 4 (ARGB_8888).
// The ImageView gets tinted white via the matrix — for a pure
// black-and-white QR that's all we need and it halves heap usage
// compared to the previous RGB_565 path.
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val pixels = IntArray(size * size)
for (y in 0 until size) {
val rowOffset = y * size
@@ -218,34 +357,54 @@ class MainActivity : Activity() {
if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
}
}
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
return bitmap
}
/**
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
* Rendered programmatically so we don't depend on the regular layout
* (which itself may reference resources affected by the failure).
* Stack trace is hidden behind a "Show details" toggle so we don't
* print user-path data on shared TV screens by default.
*/
private fun showFatalErrorScreen(error: Throwable) {
Log.e(TAG, "Fatal init error — showing error screen", error)
val stackText = android.util.Log.getStackTraceString(error)
val container = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
val stackText = Log.getStackTraceString(error)
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(48, 48, 48, 48)
}
val title = TextView(this).apply {
text = "LedGrab failed to start"
text = getString(R.string.fatal_title)
textSize = 22f
}
val description = TextView(this).apply {
text = getString(R.string.fatal_body_prefix)
textSize = 14f
setPadding(0, 24, 0, 12)
}
val body = TextView(this).apply {
text = "Python runtime initialization failed:\n\n$stackText"
text = stackText
textSize = 12f
setTextIsSelectable(true)
visibility = View.GONE
}
val scroll = ScrollView(this).apply {
addView(body)
visibility = View.GONE
}
val toggleBtn = Button(this).apply {
text = getString(R.string.fatal_show_details)
setOnClickListener {
val showing = scroll.visibility == View.VISIBLE
scroll.visibility = if (showing) View.GONE else View.VISIBLE
body.visibility = scroll.visibility
text = getString(
if (showing) R.string.fatal_show_details else R.string.fatal_hide_details,
)
}
}
val copyBtn = Button(this).apply {
text = "Copy log"
text = getString(R.string.fatal_copy_log)
setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE)
as android.content.ClipboardManager
@@ -254,19 +413,20 @@ class MainActivity : Activity() {
)
}
}
val scroll = android.widget.ScrollView(this).apply { addView(body) }
container.addView(title)
container.addView(description)
container.addView(toggleBtn)
container.addView(copyBtn)
container.addView(scroll)
setContentView(container)
}
/**
* Prompt the user to exempt LedGrab from battery optimization. On
* TV boxes this is usually a no-op, but on phones Doze/App Standby
* will kill the foreground service after a few hours of sleep. We
* only ask when autostart is turned on. No-op on pre-M or when
* already exempt.
* Prompt the user to exempt LedGrab from battery optimization.
* Strictly a phone-side concern (Doze/App Standby kill the FG
* service after hours of sleep); essentially a no-op on TV boxes.
* Only asked when autostart is turned on, which is itself only
* available on rooted devices.
*
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
* — LedGrab's ambient-capture use case falls under the documented
@@ -3,6 +3,8 @@ package com.ledgrab.android
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import java.net.Inet4Address
/**
@@ -11,18 +13,58 @@ import java.net.Inet4Address
object NetworkUtils {
/**
* Return the device's local IPv4 address on the active network,
* or `null` if unavailable.
* Return the device's local IPv4 address, preferring (in order):
* - Ethernet (wired TV-box link)
* - Wi-Fi
* - any other transport
* - whatever the active network reports
*
* Returns ``null`` only when no IPv4 link addresses exist at all.
*
* Why not just ``activeNetwork``: on TV boxes with both Ethernet
* AND Wi-Fi connected, Android's active-network heuristic can
* pick Wi-Fi while the user's phone is on the Ethernet subnet —
* leading to a URL/QR that the phone can't reach.
*/
fun getLocalIpAddress(context: Context): String? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return null
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
// TODO(AP-mode): On TV boxes acting as a Wi-Fi tether/hotspot,
// TRANSPORT_WIFI here will resolve to the AP-side interface
// (typically 192.168.43.x) which clients on the user's actual
// home LAN can't reach. Detecting AP mode requires the @SystemApi
// WifiManager.getWifiApState reflection trick — defer until a
// user reports needing it.
val networks = cm.allNetworks
if (networks.isEmpty()) return ipv4Of(cm, cm.activeNetwork ?: return null)
val ranked = networks
.mapNotNull { n ->
val caps = cm.getNetworkCapabilities(n) ?: return@mapNotNull null
val rank = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 0
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 1
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 3
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4
else -> 2
}
Triple(rank, n, caps)
}
.sortedBy { it.first }
for ((_, network, _) in ranked) {
val ip = ipv4Of(cm, network)
if (ip != null) return ip
}
return null
}
private fun ipv4Of(cm: ConnectivityManager, network: Network): String? {
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
return props.linkAddresses
.asSequence()
.map { it.address }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
.firstOrNull { !it.isLoopbackAddress && !it.isLinkLocalAddress }
?.hostAddress
}
}
@@ -9,7 +9,8 @@ import com.chaquo.python.Python
* Bridge between Kotlin and the LedGrab Python server.
*
* All Python calls go through Chaquopy's `Python.getInstance()`.
* Frame data crosses the JNI boundary as a `ByteArray`.
* Frame data crosses the JNI boundary as a `ByteArray` (reused across
* frames — see ScreenCapture / RootScreenrecord for buffer pools).
*/
class PythonBridge(private val context: Context) {
@@ -55,9 +56,14 @@ class PythonBridge(private val context: Context) {
/**
* Start the LedGrab FastAPI server on a background thread.
*
* This blocks until [stopServer] is called, so it runs in its own thread.
* Passes [apiKey] through so the Python server's auth gate accepts
* Bearer-authenticated LAN requests; null disables auth (loopback
* only — see [ApiKeyManager]).
*
* This blocks until [stopServer] is called, so it runs in its own
* thread.
*/
fun startServer(port: Int = 8080) {
fun startServer(port: Int = 8080, apiKey: String? = null) {
if (running) {
Log.w(TAG, "Server already running")
return
@@ -71,7 +77,11 @@ class PythonBridge(private val context: Context) {
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
val py = Python.getInstance()
val entry = py.getModule("ledgrab.android_entry")
entry.callAttr("start_server", dataDir, port)
if (apiKey != null) {
entry.callAttr("start_server", dataDir, port, apiKey)
} else {
entry.callAttr("start_server", dataDir, port)
}
} catch (e: Exception) {
Log.e(TAG, "Python server error", e)
} finally {
@@ -106,7 +116,8 @@ class PythonBridge(private val context: Context) {
*
* Called from [ScreenCapture] on the capture thread. The byte array
* crosses the JNI boundary — keep frames small (downscale to 480p
* before calling).
* before calling) and pass reusable buffers (see ScreenCapture's
* buffer pool).
*/
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
if (!running) return
@@ -100,14 +100,41 @@ object Root {
}
/**
* Run an `su -c <cmd>` command. Returns true on exit-zero. Failure
* invalidates the cached grant so the next [requestGrant] re-checks
* (covers cases like Magisk grant being revoked mid-session).
* Run a command as root.
*
* The [argv] array is passed to `su -c` as **a single string** built by
* shell-quoting each element. This prevents the shell-injection class
* of bug where a caller passes user-influenced data containing
* spaces, semicolons, or backticks: each element is treated as a
* single shell token regardless of contents.
*
* Returns true on exit-zero. Failure invalidates the cached grant so
* the next [requestGrant] re-checks (covers cases like Magisk grant
* being revoked mid-session).
*/
@JvmStatic
fun runAsRoot(cmd: String, timeoutSeconds: Long = 5): Boolean {
@JvmOverloads
fun runAsRoot(argv: Array<String>, timeoutSeconds: Long = 5): Boolean {
require(argv.isNotEmpty()) { "runAsRoot called with empty argv" }
val quoted = argv.joinToString(" ") { shellQuote(it) }
return execSu(quoted, timeoutSeconds)
}
/**
* Convenience for fully-trusted constant commands (e.g.
* ``runAsRoot("pkill -TERM screenrecord")``). DO NOT pass anything
* derived from user input through this overload — use [runAsRoot]
* with an argv array instead so each token is quoted individually.
*/
@JvmStatic
@JvmOverloads
fun runAsRoot(command: String, timeoutSeconds: Long = 5): Boolean {
return execSu(command, timeoutSeconds)
}
private fun execSu(shellLine: String, timeoutSeconds: Long): Boolean {
return try {
val process = ProcessBuilder("su", "-c", cmd)
val process = ProcessBuilder("su", "-c", shellLine)
.redirectErrorStream(true)
.start()
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
@@ -122,12 +149,34 @@ object Root {
true
}
} catch (e: Exception) {
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}")
Log.w(TAG, "runAsRoot('$shellLine') failed: ${e.message}")
cachedGranted = null
false
}
}
/**
* POSIX-shell-style single-quote escape. Wraps in single quotes and
* escapes embedded single quotes as ``'\''`` so shell metacharacters
* inside [s] are inert.
*/
private fun shellQuote(s: String): String {
if (s.isEmpty()) return "''"
// Optimisation: if the string contains only safe characters,
// skip the quoting overhead. The set is intentionally narrow —
// notably `=` is excluded because an unquoted "FOO=bar" at the
// start of a command would be parsed as a shell variable
// assignment, not a literal arg. Quoting it forces literal use.
if (s.all { it.isLetterOrDigit() || it in "_-./" }) return s
val sb = StringBuilder(s.length + 2)
sb.append('\'')
for (ch in s) {
if (ch == '\'') sb.append("'\\''") else sb.append(ch)
}
sb.append('\'')
return sb.toString()
}
/** Forget the cached grant result — useful if Magisk permission was revoked. */
@JvmStatic
fun invalidateCache() {
@@ -38,8 +38,15 @@ class RootScreenrecord(
private const val TAG = "RootScreenrecord"
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
private const val INPUT_CHUNK = 64 * 1024
// How long to back off when MediaCodec has no input buffer free.
// 50 ms keeps the input pump from busy-spinning if the decoder
// is stalled (codec init, severe stall, etc.).
private const val NO_BUFFER_BACKOFF_MS = 5L
}
// Instance is single-use: stop() permanently disposes it. Callers
// wanting to restart the pipeline must construct a new instance —
// see CaptureService.restartRootPipeline().
@Volatile private var process: Process? = null
private var decoder: MediaCodec? = null
private var imageReader: ImageReader? = null
@@ -48,7 +55,22 @@ class RootScreenrecord(
private var outputThread: Thread? = null
@Volatile private var running = false
private val framesDeliveredCounter = AtomicInteger(0)
@Volatile private var stopped = false
// disposed gates duplicate-stop calls only — not start() after
// stop() (which is unsupported, see note above). Set at the START
// of cleanup so a second concurrent stop() (rare under @Synchronized
// but possible if a future caller drops it) doesn't re-run runCatching
// blocks against already-released resources.
@Volatile private var disposed = false
// Guards process respawn vs. concurrent disposal. The input pump
// can spawn a fresh `su -c screenrecord` after EOF; without this
// lock, stop() could destroy the OLD process between spawn and
// assignment, leaving the new one orphaned (GPU encoder leak).
private val processLock = Any()
// Reusable RGBA buffer for ImageReader callbacks (single-threaded
// reader callback). See ScreenCapture for the rationale: avoids
// ~15 MB/s of per-frame garbage at 30 fps × 480×270×4 B.
private val frameBuffer: ByteArray = ByteArray(width * height * 4)
/** Monotonic count of frames pushed to the Python bridge. */
val framesDelivered: Int get() = framesDeliveredCounter.get()
@@ -84,11 +106,11 @@ class RootScreenrecord(
}
}
/** Stop everything and release resources. Idempotent. */
/** Stop everything and release resources. Idempotent. Single-use: do not call start() again. */
@Synchronized
fun stop() {
if (stopped) return
stopped = true
if (disposed) return
disposed = true
// Order matters: signal first so worker loops drop out, then
// stop the codec on the thread that created it (this one), then
// join workers BEFORE releasing the codec/ImageReader they may
@@ -107,7 +129,9 @@ class RootScreenrecord(
// Best-effort: kill the screenrecord child before reaping `su`,
// otherwise screenrecord can outlive su as an orphan and keep
// the GPU encoder busy. Fire-and-forget; ignore failures.
runCatching { Root.runAsRoot("pkill -TERM screenrecord", timeoutSeconds = 2) }
runCatching {
Root.runAsRoot(arrayOf("pkill", "-TERM", "screenrecord"), timeoutSeconds = 2)
}
runCatching { decoder?.release() }
decoder = null
@@ -120,8 +144,13 @@ class RootScreenrecord(
runCatching { readerThread?.join(500) }
readerThread = null
runCatching { process?.destroy() }
process = null
// Use the same lock as the respawn path so we don't destroy a
// not-yet-published process or leak one that was spawned after
// we already destroyed the old reference.
synchronized(processLock) {
runCatching { process?.destroy() }
process = null
}
Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})")
}
@@ -131,7 +160,7 @@ class RootScreenrecord(
readerThread = thread
val handler = Handler(thread.looper)
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 3)
reader.setOnImageAvailableListener({ r ->
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
try {
@@ -139,19 +168,17 @@ class RootScreenrecord(
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
val bytes = if (rowStride == width * pixelStride) {
ByteArray(buffer.remaining()).also { buffer.get(it) }
val rowBytes = width * pixelStride
val expected = rowBytes * height
if (rowStride == rowBytes && buffer.remaining() >= expected) {
buffer.get(frameBuffer, 0, expected)
} else {
// Strip row padding — common when width isn't a multiple of 16.
val rowBytes = width * pixelStride
ByteArray(width * height * 4).also { out ->
for (row in 0 until height) {
buffer.position(row * rowStride)
buffer.get(out, row * rowBytes, rowBytes)
}
for (row in 0 until height) {
buffer.position(row * rowStride)
buffer.get(frameBuffer, row * rowBytes, rowBytes)
}
}
bridge.pushRootFrame(bytes, width, height)
bridge.pushRootFrame(frameBuffer, width, height)
framesDeliveredCounter.incrementAndGet()
} catch (e: Exception) {
Log.w(TAG, "Root frame delivery failed: ${e.message}")
@@ -173,18 +200,26 @@ class RootScreenrecord(
}
private fun spawnScreenrecord(): Process? {
val cmd = buildString {
append("screenrecord")
append(" --output-format=h264")
append(" --size=${width}x$height")
append(" --bit-rate=$bitRate")
// argv form — passes safely through Root.runAsRoot's shell-quote
// logic so future changes to flag values can't introduce injection.
val args = arrayOf(
"screenrecord",
"--output-format=h264",
"--size=${width}x$height",
"--bit-rate=$bitRate",
// Time limit 0 isn't supported; the largest accepted is 180s.
// We restart the process ourselves if it exits early.
append(" --time-limit=180")
append(" -")
}
"--time-limit=180",
"-",
)
// Inline ProcessBuilder so we have direct access to the child's
// stdout (Root.runAsRoot returns Boolean). We still pass args
// unquoted because the entire array is a fixed program+flags
// with no user-controlled content.
return try {
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
ProcessBuilder("su", "-c", args.joinToString(" "))
.redirectErrorStream(false)
.start()
} catch (e: Exception) {
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
null
@@ -210,21 +245,56 @@ class RootScreenrecord(
// exits cleanly we respawn so capture survives
// long sessions instead of freezing after ~3min.
Log.i(TAG, "screenrecord EOF — respawning")
runCatching { process?.destroy() }
synchronized(processLock) {
runCatching { process?.destroy() }
process = null
}
val next = spawnScreenrecord()
if (next == null) {
// Avoid a tight loop if `su` is suddenly unhappy.
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
continue@outer
}
process = next
// Publish the new process under the lock so a
// concurrent stop() either (a) sees no process,
// tears down later, and lets us assign it for
// the destroy on the NEXT stop call — or (b) sees
// !running and we destroy the new process ourselves.
val accepted = synchronized(processLock) {
if (!running) {
false
} else {
process = next
true
}
}
if (!accepted) {
// running flipped false between EOF and now —
// someone called stop(). Drop the new process
// on the floor; the codec and output thread
// are stop()'s responsibility (it's the only
// writer to `running`, so we don't need to
// tear them down here).
runCatching { next.destroy() }
break@outer
}
stream = next.inputStream
continue@outer
}
var offset = 0
while (offset < n && running) {
val index = codec.dequeueInputBuffer(50_000)
if (index < 0) continue
if (index < 0) {
// Codec is starved — back off briefly instead
// of spinning. Without this, a stalled codec
// burns 100% of one core hammering dequeue.
try {
Thread.sleep(NO_BUFFER_BACKOFF_MS)
} catch (_: InterruptedException) {
break
}
continue
}
val inputBuffer = codec.getInputBuffer(index) ?: continue
inputBuffer.clear()
val chunk = minOf(n - offset, inputBuffer.capacity())
@@ -1,6 +1,5 @@
package com.ledgrab.android
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
@@ -8,24 +7,26 @@ import android.media.ImageReader
import android.media.projection.MediaProjection
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.util.DisplayMetrics
import android.util.Log
import java.nio.ByteBuffer
/**
* Captures the Android screen via MediaProjection and feeds frames
* to [PythonBridge].
*
* Frames are downscaled to [targetWidth] x [targetHeight] before
* crossing the JNI boundary to minimize overhead. For LED ambient
* lighting, even 480x270 contains far more data than needed.
* Frames are downscaled to roughly [targetWidth] x [targetHeight] before
* crossing the JNI boundary to minimize overhead. The actual capture
* dimensions preserve the source screen's aspect ratio (snapped to even
* pixels for codec friendliness) so non-16:9 displays don't get
* squashed.
*/
class ScreenCapture(
private val projection: MediaProjection,
private val metrics: DisplayMetrics,
private val bridge: PythonBridge,
private val targetWidth: Int = 480,
private val targetHeight: Int = 270,
targetWidth: Int = 480,
targetHeight: Int = 270,
private val targetFps: Int = 30,
private val onProjectionStopped: () -> Unit = {},
) {
@@ -34,13 +35,51 @@ class ScreenCapture(
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
}
// Snap to the source aspect ratio so we don't squash 21:9 / portrait
// / rotated screens. Width is the budget; height follows.
private val captureWidth: Int
private val captureHeight: Int
init {
val srcW = metrics.widthPixels.coerceAtLeast(1).toFloat()
val srcH = metrics.heightPixels.coerceAtLeast(1).toFloat()
val budget = targetWidth.coerceAtLeast(16)
val aspect = srcW / srcH
val w = budget
val h = (w / aspect).toInt().coerceAtLeast(16)
// Bias toward even dimensions — some encoders/ImageReaders are
// unhappy with odd sizes when row strides come into play.
captureWidth = (w and 1.inv()).coerceAtLeast(16)
captureHeight = (h and 1.inv()).coerceAtLeast(16)
if (captureWidth != targetWidth || captureHeight != targetHeight) {
Log.i(
TAG,
"Capture size adjusted for ${srcW.toInt()}x${srcH.toInt()} " +
"(${"%.2f".format(aspect)}:1) → ${captureWidth}x$captureHeight",
)
}
}
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var captureThread: HandlerThread? = null
private var captureHandler: Handler? = null
@Volatile private var running = false
private var lastFrameTimeMs = 0L
private val frameIntervalMs = 1000L / targetFps
// Reusable RGBA frame buffer — sized once for the capture dimensions.
// The capture handler is single-threaded so no synchronisation is
// required around this buffer (each callback runs to completion
// before the next is dispatched). Eliminates ~15 MB/s of per-frame
// garbage at 30 fps × 480×270×4 B that previously caused GC pauses
// on low-end TV boxes.
private val frameBuffer: ByteArray = ByteArray(captureWidth * captureHeight * 4)
// Monotonic frame pacing. `nextFrameNanos` is the target render
// time of the next frame; carrying it forward as an accumulator
// avoids the integer-division drift the wall-clock version had
// (e.g. 30 fps → 33 ms produced ~30.3 fps).
private val frameIntervalNanos = (1_000_000_000L / targetFps.coerceAtLeast(1))
private var nextFrameNanos = 0L
/**
* Start capturing the screen.
@@ -48,6 +87,7 @@ class ScreenCapture(
fun start() {
if (running) return
running = true
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
captureHandler = Handler(captureThread!!.looper)
@@ -56,28 +96,32 @@ class ScreenCapture(
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped (external)")
stop()
// Notify the service so the foreground notification /
// Python server get torn down too — otherwise a stale
// "Running" notification lingers after the user taps
// Android's system Cast/Screen-capture stop banner.
// We're on captureHandler's thread here — calling stop()
// directly would self-join captureThread (handler.join()
// from inside the handler thread hangs until the join
// timeout, then closes resources while we're STILL
// inside this callback). Just flip `running` to halt
// frame processing and hand off to the service; its
// onDestroy will call stop() from the main thread,
// which is safe to join captureThread from.
running = false
onProjectionStopped()
}
}, captureHandler)
imageReader = ImageReader.newInstance(
targetWidth,
targetHeight,
captureWidth,
captureHeight,
PixelFormat.RGBA_8888,
2, // maxImages — double buffer
3, // maxImages — small ring buffer; 3 is more forgiving than 2 under jitter
)
imageReader?.setOnImageAvailableListener({ reader ->
if (!running) return@setOnImageAvailableListener
val now = System.currentTimeMillis()
if (now - lastFrameTimeMs < frameIntervalMs) {
// Skip frame to maintain target FPS
val now = SystemClock.elapsedRealtimeNanos()
if (now < nextFrameNanos) {
// Too early — drop this image to stay on cadence.
reader.acquireLatestImage()?.close()
return@setOnImageAvailableListener
}
@@ -88,26 +132,30 @@ class ScreenCapture(
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
val rowBytes = captureWidth * pixelStride
val expected = rowBytes * captureHeight
// Handle row padding: rowStride may be > width * pixelStride
val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
// No padding — direct copy
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
bytes
// Fill the reusable buffer. Two paths:
// - rowStride == rowBytes: bulk get into the buffer
// - rowStride > rowBytes: row-by-row copy stripping padding
if (rowStride == rowBytes && buffer.remaining() >= expected) {
buffer.get(frameBuffer, 0, expected)
} else {
// Strip row padding
val rowBytes = targetWidth * pixelStride
val bytes = ByteArray(targetWidth * targetHeight * 4)
for (row in 0 until targetHeight) {
for (row in 0 until captureHeight) {
buffer.position(row * rowStride)
buffer.get(bytes, row * rowBytes, rowBytes)
buffer.get(frameBuffer, row * rowBytes, rowBytes)
}
bytes
}
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
lastFrameTimeMs = now
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
// Advance the pacing accumulator. If we fell badly behind
// (long GC, JNI stall), snap forward to "now" instead of
// accumulating a burst of catch-up frames.
nextFrameNanos += frameIntervalNanos
if (now - nextFrameNanos > frameIntervalNanos * 4) {
nextFrameNanos = now + frameIntervalNanos
}
} catch (e: Exception) {
Log.w(TAG, "Frame processing error: ${e.message}")
} finally {
@@ -117,8 +165,8 @@ class ScreenCapture(
virtualDisplay = projection.createVirtualDisplay(
VIRTUAL_DISPLAY_NAME,
targetWidth,
targetHeight,
captureWidth,
captureHeight,
metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader?.surface,
@@ -126,7 +174,7 @@ class ScreenCapture(
captureHandler,
)
Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)")
Log.i(TAG, "Screen capture started (${captureWidth}x${captureHeight} @ ${targetFps}fps)")
}
/**
@@ -8,6 +8,7 @@ import android.content.IntentFilter
import android.hardware.usb.UsbManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
@@ -54,8 +55,23 @@ object UsbSerialBridge {
if (!initialized.compareAndSet(false, true)) return
val filter = IntentFilter(ACTION_USB_PERMISSION)
val ourPackage = app.packageName
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
// Defence-in-depth: the receiver is registered as
// RECEIVER_NOT_EXPORTED, but on pre-API-33 platforms
// older Android versions historically defaulted to
// exported. Also enforce the package check here so an
// explicit-intent attack from another app on the device
// is rejected even if the OS treats us as exported.
if (intent.`package` != null && intent.`package` != ourPackage) {
Log.w(
TAG,
"Ignoring USB permission broadcast from " +
"package='${intent.`package`}' (not us)",
)
return
}
val granted = intent.getBooleanExtra(
UsbManager.EXTRA_PERMISSION_GRANTED,
false,
@@ -69,13 +85,16 @@ object UsbSerialBridge {
}
}
}
// Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
app.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
app.registerReceiver(receiver, filter)
}
// ContextCompat handles the RECEIVER_NOT_EXPORTED flag correctly
// across all supported API levels (it's a no-op on platforms
// where the flag doesn't exist, and explicit on API ≥33 where
// Android enforces it).
ContextCompat.registerReceiver(
app,
receiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)
}
private fun ctx(): Context =
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Android TV launcher banner: 320x180 landscape.
Shown on the leanback home row. The previous build reused the square
launcher icon, which letterboxed badly. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<!-- Background -->
<path
android:fillColor="#0d1117"
android:pathData="M0,0 L320,0 L320,180 L0,180 Z" />
<!-- Subtle teal glow top-left -->
<path
android:fillColor="#1A64ffda"
android:pathData="M0,0 L160,0 L160,90 L0,90 Z" />
<!-- Subtle purple glow bottom-right -->
<path
android:fillColor="#15bb86fc"
android:pathData="M160,90 L320,90 L320,180 L160,180 Z" />
<!-- TV body, centered -->
<path
android:fillColor="#1c2333"
android:pathData="M88,56 L196,56 Q204,56 204,64 L204,116 Q204,124 196,124 L88,124 Q80,124 80,116 L80,64 Q80,56 88,56 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M92,60 L192,60 Q196,60 196,64 L196,116 Q196,120 192,120 L92,120 Q88,120 88,116 L88,64 Q88,60 92,60 Z" />
<!-- LED glow strips -->
<path
android:fillColor="#64ffda"
android:fillAlpha="0.8"
android:pathData="M94,50 L190,50 L190,54 L94,54 Z" />
<path
android:fillColor="#bb86fc"
android:fillAlpha="0.7"
android:pathData="M72,62 L76,62 L76,118 L72,118 Z" />
<path
android:fillColor="#ff6b6b"
android:fillAlpha="0.7"
android:pathData="M208,62 L212,62 L212,118 L208,118 Z" />
<path
android:fillColor="#ffd93d"
android:fillAlpha="0.7"
android:pathData="M94,126 L190,126 L190,130 L94,130 Z" />
<!-- Wordmark "LedGrab" — drawn as paths so we don't depend on the
system font cache being warm at TV launch. -->
<!-- L -->
<path android:fillColor="#64ffda"
android:pathData="M222,72 L228,72 L228,100 L240,100 L240,106 L222,106 Z" />
<!-- e -->
<path android:fillColor="#e6edf3"
android:pathData="M244,82 L260,82 Q264,82 264,86 L264,94 L250,94 L250,100 L262,100 L262,106 L246,106 Q244,106 244,104 Z M250,86 L250,90 L258,90 L258,86 Z" />
<!-- d -->
<path android:fillColor="#e6edf3"
android:pathData="M266,72 L272,72 L272,82 L284,82 Q286,82 286,84 L286,106 L268,106 Q266,106 266,104 Z M272,88 L272,100 L280,100 L280,88 Z" />
</vector>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static fallback for the status dot. The animated version
(animated_status_dot.xml) is used at runtime; this is what
XML rendering tools show in the editor. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/green_status" />
<size android:width="18dp" android:height="18dp" />
</shape>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome status-bar icon. Android requires white-on-transparent for
notification icons since API 21 - reusing the colored launcher would
render as a gray blob. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<!-- TV body -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5,7 L19,7 Q20,7 20,8 L20,16 Q20,17 19,17 L5,17 Q4,17 4,16 L4,8 Q4,7 5,7 Z M5.5,8.5 L5.5,15.5 L18.5,15.5 L18.5,8.5 Z" />
<!-- TV stand -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M10,17 L10,18.5 L14,18.5 L14,17 Z M9,19 L15,19 L15,20 L9,20 Z" />
<!-- LED glow strips around the TV (bright dots) -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M6,5.5 L18,5.5 L18,6.5 L6,6.5 Z" />
</vector>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Splash screen icon (API 31+ uses a 1:1 vector inside a 240dp circle).
The SplashScreen API masks this with a circle automatically. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="240dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- TV body -->
<path
android:fillColor="#1c2333"
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
<!-- LED glow strips, brighter on splash for impact -->
<path
android:fillColor="#64ffda"
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
<path
android:fillColor="#bb86fc"
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
<path
android:fillColor="#ff6b6b"
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
<path
android:fillColor="#ffd93d"
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
<!-- TV stand -->
<path
android:fillColor="#1c2333"
android:pathData="M44,72 L44,78 L64,78 L64,72" />
<path
android:fillColor="#1c2333"
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
</vector>
+27 -115
View File
@@ -32,16 +32,28 @@
android:textStyle="bold"
android:letterSpacing="0.08"
android:layout_marginBottom="12dp"
android:fontFamily="sans-serif-light" />
android:fontFamily="sans-serif" />
<TextView
android:id="@+id/status_text"
android:id="@+id/tagline_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tagline"
android:textColor="@color/text_secondary"
android:textSize="28sp"
android:layout_marginBottom="64dp" />
android:layout_marginBottom="24dp" />
<!-- Transient status (root probing / permission denial). Always
present so the layout doesn't reflow when text appears. -->
<TextView
android:id="@+id/status_text"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="20sp"
android:layout_marginBottom="32dp"
tools:text="Checking root access…" />
<Button
android:id="@+id/toggle_button"
@@ -51,7 +63,8 @@
android:text="@string/btn_start"
android:textSize="22sp"
android:focusable="true"
android:focusableInTouchMode="true" />
android:focusableInTouchMode="true"
android:nextFocusDown="@+id/autostart_check" />
<CheckBox
android:id="@+id/autostart_check"
@@ -63,10 +76,11 @@
android:textSize="20sp"
android:buttonTint="@color/teal_accent"
android:focusable="true"
android:focusableInTouchMode="true" />
android:focusableInTouchMode="true"
android:nextFocusUp="@id/toggle_button" />
</LinearLayout>
<!-- Version at bottom -->
<!-- Version at bottom (always visible — looks polished on TV idle). -->
<TextView
android:id="@+id/version_text"
android:layout_width="wrap_content"
@@ -77,115 +91,13 @@
android:textSize="18sp"
tools:text="v0.1.0" />
<!-- RUNNING STATE -->
<LinearLayout
android:id="@+id/running_panel"
<!-- RUNNING STATE — deferred-inflate via ViewStub so first paint is
cheaper and the inflater doesn't measure two competing layouts. -->
<ViewStub
android:id="@+id/running_panel_stub"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="120dp"
android:paddingEnd="120dp"
android:paddingTop="80dp"
android:paddingBottom="80dp"
android:visibility="gone">
<!-- Left: status + URL + stop -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="start|center_vertical"
android:paddingEnd="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<View
android:layout_width="18dp"
android:layout_height="18dp"
android:background="@drawable/bg_status_dot"
android:layout_marginEnd="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_running"
android:textColor="@color/green_status"
android:textSize="28sp"
android:textStyle="bold"
android:letterSpacing="0.05" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_web_ui"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/url_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/teal_accent"
android:textSize="30sp"
android:maxLines="1"
android:textStyle="bold"
android:background="@drawable/bg_url_chip"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:layout_marginBottom="56dp"
tools:text="http://192.168.1.5:8080" />
<Button
android:id="@+id/stop_button_running"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="240dp"
android:layout_height="64dp"
android:text="@string/btn_stop"
android:textSize="20sp"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Right: QR code -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_qr_container"
android:padding="20dp"
android:layout_marginBottom="20dp">
<ImageView
android:id="@+id/qr_image"
android:layout_width="280dp"
android:layout_height="280dp"
android:contentDescription="@string/qr_description"
android:scaleType="fitXY" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_to_configure"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:gravity="center" />
</LinearLayout>
</LinearLayout>
android:inflatedId="@+id/running_panel"
android:layout="@layout/panel_running"
android:visibility="gone" />
</FrameLayout>
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- RUNNING STATE -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="120dp"
android:paddingEnd="120dp"
android:paddingTop="80dp"
android:paddingBottom="80dp">
<!-- Left: status + URL + stop -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="start|center_vertical"
android:paddingEnd="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<View
android:id="@+id/status_dot"
android:layout_width="18dp"
android:layout_height="18dp"
android:background="@drawable/bg_status_dot"
android:layout_marginEnd="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_running"
android:textColor="@color/green_status"
android:textSize="28sp"
android:textStyle="bold"
android:letterSpacing="0.05" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_web_ui"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/url_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/teal_accent"
android:textSize="30sp"
android:maxLines="1"
android:textStyle="bold"
android:background="@drawable/bg_url_chip"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:layout_marginBottom="56dp"
tools:text="http://192.168.1.5:8080" />
<Button
android:id="@+id/stop_button_running"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="240dp"
android:layout_height="64dp"
android:text="@string/btn_stop"
android:textSize="20sp"
android:focusable="true"
android:focusableInTouchMode="true"
android:nextFocusUp="@id/stop_button_running"
android:nextFocusDown="@id/stop_button_running" />
</LinearLayout>
<!-- Right: QR code + fallback hint -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_qr_container"
android:padding="20dp"
android:layout_marginBottom="20dp">
<ImageView
android:id="@+id/qr_image"
android:layout_width="280dp"
android:layout_height="280dp"
android:contentDescription="@string/qr_description"
android:scaleType="fitXY" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_to_configure"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:gravity="center" />
<TextView
android:layout_width="280dp"
android:layout_height="wrap_content"
android:text="@string/scan_fallback_hint"
android:textColor="@color/text_hint"
android:textSize="14sp"
android:gravity="center"
android:layout_marginTop="6dp" />
</LinearLayout>
</LinearLayout>
@@ -3,12 +3,26 @@
<string name="app_name">LedGrab</string>
<string name="tagline">Фоновая подсветка для телевизора</string>
<string name="btn_start">Начать захват</string>
<string name="btn_starting">Запуск…</string>
<string name="btn_stop">Стоп</string>
<string name="status_running">Работает</string>
<string name="status_checking_root">Проверка root-доступа…</string>
<string name="status_permission_denied">Доступ запрещён — для захвата экрана требуется разрешение</string>
<string name="status_no_network">Нет сети — подключите Wi-Fi или Ethernet</string>
<string name="label_web_ui">Адрес веб-интерфейса</string>
<string name="scan_to_configure">Сканируйте для настройки</string>
<string name="scan_fallback_hint">или откройте этот адрес с любого устройства в сети</string>
<string name="qr_description">QR-код для веб-интерфейса</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">Запускать при загрузке (только с root)</string>
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string>
<string name="fatal_title">Не удалось запустить LedGrab</string>
<string name="fatal_body_prefix">Ошибка инициализации Python:</string>
<string name="fatal_copy_log">Скопировать журнал</string>
<string name="fatal_show_details">Показать подробности</string>
<string name="fatal_hide_details">Скрыть подробности</string>
<string name="notification_channel_name">Захват LedGrab</string>
<string name="notification_channel_description">Отображается, пока LedGrab захватывает экран.</string>
<string name="notification_title">LedGrab работает</string>
<string name="notification_text">Веб-интерфейс: %1$s</string>
</resources>
@@ -3,12 +3,26 @@
<string name="app_name">LedGrab</string>
<string name="tagline">电视氛围灯光</string>
<string name="btn_start">开始捕获</string>
<string name="btn_starting">正在启动…</string>
<string name="btn_stop">停止</string>
<string name="status_running">运行中</string>
<string name="status_checking_root">正在检查 root 权限…</string>
<string name="status_permission_denied">权限被拒绝 — 屏幕捕获需要授权</string>
<string name="status_no_network">无网络 — 请连接 Wi-Fi 或以太网</string>
<string name="label_web_ui">Web界面地址</string>
<string name="scan_to_configure">扫码配置</string>
<string name="scan_fallback_hint">或在同一网络的任何设备上访问上方网址</string>
<string name="qr_description">Web界面二维码</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">开机自启(仅限 root)</string>
<string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string>
<string name="fatal_title">LedGrab 启动失败</string>
<string name="fatal_body_prefix">Python 运行时初始化失败:</string>
<string name="fatal_copy_log">复制日志</string>
<string name="fatal_show_details">显示详情</string>
<string name="fatal_hide_details">隐藏详情</string>
<string name="notification_channel_name">LedGrab 屏幕捕获</string>
<string name="notification_channel_description">LedGrab 捕获屏幕时显示。</string>
<string name="notification_title">LedGrab 运行中</string>
<string name="notification_text">Web界面:%1$s</string>
</resources>
@@ -3,12 +3,26 @@
<string name="app_name">LedGrab</string>
<string name="tagline">Ambient lighting for your TV</string>
<string name="btn_start">Start Capture</string>
<string name="btn_starting">Starting…</string>
<string name="btn_stop">Stop</string>
<string name="status_running">Running</string>
<string name="status_checking_root">Checking root access…</string>
<string name="status_permission_denied">Permission denied — screen capture requires authorization</string>
<string name="status_no_network">No network — connect Wi-Fi or Ethernet</string>
<string name="label_web_ui">Web UI address</string>
<string name="scan_to_configure">Scan to configure</string>
<string name="scan_fallback_hint">or visit the URL above on any device on this network</string>
<string name="qr_description">QR code for web UI</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">Start on boot (root only)</string>
<string name="autostart_unavailable">Start on boot — unavailable (root required)</string>
<string name="fatal_title">LedGrab failed to start</string>
<string name="fatal_body_prefix">Python runtime initialization failed:</string>
<string name="fatal_copy_log">Copy log</string>
<string name="fatal_show_details">Show details</string>
<string name="fatal_hide_details">Hide details</string>
<string name="notification_channel_name">LedGrab capture</string>
<string name="notification_channel_description">Shows while LedGrab is capturing the screen.</string>
<string name="notification_title">LedGrab Running</string>
<string name="notification_text">Web UI: %1$s</string>
</resources>
@@ -12,6 +12,16 @@
<item name="android:colorControlActivated">@color/teal_accent</item>
</style>
<!-- Splash screen theme. Compatible across API levels via the
androidx.core:core-splashscreen library. On API 31+ the system
splash uses the foreground icon; on older versions the launch
theme just paints the navy background, which is harmless. -->
<style name="Theme.LedGrab.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/bg_navy</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
<item name="postSplashScreenTheme">@style/Theme.LedGrab</item>
</style>
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
<item name="android:background">@drawable/bg_button_primary</item>
<item name="android:textColor">@color/bg_navy</item>
@@ -1,8 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
brokers on the local network via plain HTTP/UDP. Cleartext traffic
must be allowed for these connections to work on Android 9+.
LedGrab is a LAN-only app:
- Inbound: web UI / API on the device (HTTP, port 8080)
- Outbound: WLED HTTP/UDP, Home Assistant, MQTT brokers, mDNS
All of these are plaintext on the local network. Android's network
security config doesn't support CIDR allowlists, so we cannot
restrict cleartext to RFC1918 ranges declaratively — we have to
permit cleartext base-wide.
Defence-in-depth that ACTUALLY mitigates this:
1. Inbound: the FastAPI server in this app rejects non-loopback
requests when no API key is configured (see ledgrab.api.auth).
The Android launcher auto-generates an API key on first run
(see ApiKeyManager.kt) and injects it via the
LEDGRAB_AUTH__API_KEYS env var before uvicorn starts. The
user's phone receives the key by scanning the QR, which
embeds the key as a URL fragment (never logged server-side).
2. Outbound: targets are validated by net_classify in the Python
layer (LAN-only HTTP, SSRF-safe).
DO NOT remove the cleartext permission without first migrating
every LAN peer to HTTPS — most WLED firmware, mDNS, and the LAN
HTTP server itself rely on this flag.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
+17 -12
View File
@@ -1,16 +1,18 @@
#!/usr/bin/env bash
#
# Cross-compile pydantic-core for Android across all three ABIs:
# arm64-v8a (primary — real TV hardware)
# x86_64 (modern emulators)
# x86 (legacy emulators)
# Cross-compile pydantic-core for Android across all supported ABIs:
# arm64-v8a (primary — modern TV hardware)
# x86_64 (modern emulators)
# x86 (legacy emulators)
# armeabi-v7a (32-bit ARMv7 — older cheap TV boxes like X96 mini, MeCool)
#
# Outputs wheels into android/wheels/. Wheels are linked against the real
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
# memory/project_android_app.md for the incident notes).
#
# Prerequisites (on host):
# - Rust + cargo (rustup) with targets: aarch64/x86_64/i686-linux-android
# - Rust + cargo (rustup) with targets:
# aarch64/x86_64/i686/armv7a-linux-android(eabi)
# - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*)
# - Python 3.11 (matches Chaquopy's embedded version)
# - maturin (pip install maturin)
@@ -19,9 +21,10 @@
# core dependency version changes.
#
# Usage:
# ./build-pydantic-core.sh # build all three ABIs
# ./build-pydantic-core.sh arm64 # build a single ABI
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
# ./build-pydantic-core.sh # build all 4 ABIs
# ./build-pydantic-core.sh arm64 # build a single ABI
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
# ./build-pydantic-core.sh armv7 # 32-bit ARM only
#
set -euo pipefail
@@ -91,21 +94,23 @@ fi
# ── ABI table ───────────────────────────────────────────────────────
# Columns: short_name rust_target clang_prefix sysconfig_dir
ABI_TABLE=(
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
"armv7 armv7-linux-androideabi armv7a-linux-androideabi${API_LEVEL} cross-sysconfig-armv7"
)
declare -A ABI_TAG_MAP=(
[arm64]="arm64_v8a"
[x86_64]="x86_64"
[x86]="x86"
[armv7]="armeabi_v7a"
)
# ── Select which ABIs to build ──────────────────────────────────────
SELECTED=("$@")
if [ ${#SELECTED[@]} -eq 0 ]; then
SELECTED=(arm64 x86_64 x86)
SELECTED=(arm64 x86_64 x86 armv7)
fi
# ── Ensure rust targets are installed ───────────────────────────────
+30 -1
View File
@@ -6,6 +6,7 @@ inside an Android application. Sets up Android-specific paths
"""
import asyncio
import json
import os
import threading
from typing import Any
@@ -15,7 +16,7 @@ _server: Any | None = None # uvicorn.Server
_loop: asyncio.AbstractEventLoop | None = None
def start_server(data_dir: str, port: int = 8080) -> None:
def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) -> None:
"""Start the LedGrab uvicorn server.
Called from Kotlin's ``PythonBridge.startServer()``. This function
@@ -26,6 +27,11 @@ def start_server(data_dir: str, port: int = 8080) -> None:
data_dir: Android app-private files directory
(e.g. ``/data/data/com.ledgrab.android/files``).
port: HTTP port for the web UI / API.
api_key: Optional Bearer token to enable LAN auth. When set,
published as ``LEDGRAB_AUTH__API_KEYS={"android":<key>}``
so the server's auth gate accepts LAN requests carrying
``Authorization: Bearer <key>``. When None, the server
falls back to its default (loopback-only).
"""
# ── Configure paths before any LedGrab imports ──────────────
os.makedirs(os.path.join(data_dir, "data"), exist_ok=True)
@@ -41,6 +47,14 @@ def start_server(data_dir: str, port: int = 8080) -> None:
os.environ["LEDGRAB_SERVER__HOST"] = "0.0.0.0"
os.environ["LEDGRAB_SERVER__PORT"] = str(port)
# Provision LAN auth when the Kotlin launcher supplied a key. The
# config layer (pydantic-settings) parses ``LEDGRAB_AUTH__API_KEYS``
# as JSON when the value starts with `{`. We use a dict so the
# rest of the codebase sees a labelled key just like the YAML
# config form (api_keys: {android: ...}).
if api_key:
os.environ["LEDGRAB_AUTH__API_KEYS"] = json.dumps({"android": api_key})
# ── Now safe to import LedGrab ──────────────────────────────
import uvicorn # noqa: E402
@@ -50,10 +64,25 @@ def start_server(data_dir: str, port: int = 8080) -> None:
logger = get_logger(__name__)
logger.info("LedGrab Android: starting server on port %d", port)
logger.info("Data directory: %s", data_dir)
if api_key:
logger.info("LedGrab Android: API key auth enabled (label=android)")
else:
logger.warning("LedGrab Android: no API key — LAN requests will be rejected")
from ledgrab.config import get_config # noqa: E402
config = get_config()
# Defensive: confirm the env var actually landed in the parsed config.
# If pydantic-settings ever changes how it deserialises dict[str, str]
# from env, the LAN auth would silently break (server would 401 every
# phone scan). Logging the mismatch makes the failure mode obvious in
# adb logcat.
if api_key and config.auth.api_keys.get("android") != api_key:
logger.error(
"LedGrab Android: API key did NOT land in config — LAN auth will "
"reject all requests. Check pydantic-settings dict parsing for "
"LEDGRAB_AUTH__API_KEYS."
)
uv_config = uvicorn.Config(
"ledgrab.main:app",
+39
View File
@@ -244,6 +244,30 @@ import {
initDonationBanner, dismissDonation, snoozeDonation, renderAboutPanel, setProjectUrls,
} from './features/donation.ts';
// ─── Out-of-band API key delivery (URL fragment) ───
/**
* Parse the URL fragment for an API key delivered out-of-band.
* Supported forms:
* #k=<token>
* #key=<token>
* #/some-route#k=<token> (key wins; route still applies)
*
* Returns null when no key is present or the value looks invalid
* (whitespace / suspicious characters). Tokens are constrained to a
* conservative URL-safe alphabet to avoid accepting arbitrary input
* — the Android launcher uses 64-char hex.
*/
function readApiKeyFromFragment(): string | null {
const hash = location.hash;
if (!hash) return null;
// Strip leading '#' once; search inside for either ?k= or &k= or
// a leading k=, plus the `key=` long form.
const body = hash.replace(/^#/, '');
const match = body.match(/(?:^|[?&#])(?:k|key)=([A-Za-z0-9_-]{16,512})/);
return match ? match[1] : null;
}
// ─── Register all HTML onclick / onchange / onfocus globals ───
Object.assign(window, {
@@ -724,6 +748,21 @@ window.addEventListener('beforeunload', () => {
document.addEventListener('DOMContentLoaded', async () => {
try {
// Bootstrap auth: first check the URL fragment for a key delivered
// out-of-band (e.g. the Android TV launcher embeds the per-install
// API key in the QR as ``#k=<token>``). Fragments are never sent in
// HTTP requests so this is safe to log. Persist to localStorage and
// strip the hash so a refresh doesn't keep showing it in the URL.
const tokenFromFragment = readApiKeyFromFragment();
if (tokenFromFragment) {
localStorage.setItem('ledgrab_api_key', tokenFromFragment);
try {
history.replaceState(null, '', location.pathname + location.search);
} catch {
// Older browsers without history API support — leave the
// fragment alone; it's already cached in localStorage.
}
}
// Load API key from localStorage before anything that triggers API calls
setApiKey(localStorage.getItem('ledgrab_api_key'));
@@ -0,0 +1,113 @@
/**
* Typed REST client — one place for the request/parse/error-unwrap dance
* that every feature module used to hand-roll on top of `fetchWithAuth`.
*
* Before this module, ~25 feature files repeated the same shape:
*
* ```ts
* const resp = await fetchWithAuth(url, { method, body: JSON.stringify(p) });
* if (!resp.ok) {
* const err = await resp.json().catch(() => ({}));
* throw new Error(err.detail || `HTTP ${resp.status}`);
* }
* const data = await resp.json();
* ```
*
* `apiGet` / `apiPost` / `apiPut` / `apiDelete` collapse that to a single
* call that returns the parsed body (typed via the caller's `<T>`) and
* throws an {@link ApiError} carrying the server's `detail` on failure.
*
* Auth headers, the 401 → re-login flow, timeouts, the 5xx/network retry
* loop, and the offline-toast are all still owned by `fetchWithAuth`; this
* is a thin typed layer on top, not a replacement.
*
* Audit finding M7.
*/
import { fetchWithAuth, ApiError } from './api.ts';
export interface ApiRequestOpts {
/**
* Message for the thrown {@link ApiError} when the server returns a
* non-2xx status *and* provides no usable `detail`. Defaults to
* `HTTP <status>`. Pass a localised string (e.g. `t('foo.error.save')`)
* to preserve the bespoke per-feature messages.
*/
errorMessage?: string;
/** Abort signal forwarded to `fetchWithAuth`. */
signal?: AbortSignal;
/** Per-request timeout in ms (default 10 000, owned by `fetchWithAuth`). */
timeout?: number;
/** Disable the 5xx/network auto-retry loop (default: enabled). */
retry?: boolean;
}
/**
* Turn a `Response` into a parsed body or throw {@link ApiError}.
*
* `detail` handling mirrors — and slightly hardens — the old hand-rolled
* pattern: a string `detail` is used verbatim; FastAPI validation errors
* (an array of `{msg, ...}`) are joined instead of stringifying to
* `[object Object]`; otherwise we fall back to `errorMessage` then
* `HTTP <status>`.
*/
async function unwrap<T>(resp: Response, opts?: ApiRequestOpts): Promise<T> {
if (!resp.ok) {
const body = await resp.json().catch(() => ({} as Record<string, unknown>));
const detail = (body as { detail?: unknown }).detail;
let message: string;
if (typeof detail === 'string' && detail) {
message = detail;
} else if (Array.isArray(detail) && detail.length > 0) {
message = detail
.map((d) => (d && typeof d === 'object' && 'msg' in d ? String((d as { msg: unknown }).msg) : String(d)))
.join('; ');
} else {
message = opts?.errorMessage || `HTTP ${resp.status}`;
}
throw new ApiError(resp.status, message);
}
// 204 No Content (and other empty bodies) — nothing to parse.
if (resp.status === 204) return undefined as T;
const text = await resp.text();
return (text ? JSON.parse(text) : undefined) as T;
}
function buildOpts(method: string, body: unknown, opts?: ApiRequestOpts): RequestInit & { retry?: boolean; timeout?: number } {
const init: RequestInit & { retry?: boolean; timeout?: number } = { method };
if (body !== undefined) init.body = JSON.stringify(body);
if (opts?.signal) init.signal = opts.signal;
if (opts?.timeout !== undefined) init.timeout = opts.timeout;
if (opts?.retry !== undefined) init.retry = opts.retry;
return init;
}
/** `GET <path>` → parsed JSON body of type `T`. */
export async function apiGet<T>(path: string, opts?: ApiRequestOpts): Promise<T> {
const resp = await fetchWithAuth(path, buildOpts('GET', undefined, opts));
return unwrap<T>(resp, opts);
}
/** `POST <path>` with a JSON body → parsed JSON body of type `T`. */
export async function apiPost<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
const resp = await fetchWithAuth(path, buildOpts('POST', body, opts));
return unwrap<T>(resp, opts);
}
/** `PUT <path>` with a JSON body → parsed JSON body of type `T`. */
export async function apiPut<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
const resp = await fetchWithAuth(path, buildOpts('PUT', body, opts));
return unwrap<T>(resp, opts);
}
/** `PATCH <path>` with a JSON body → parsed JSON body of type `T`. */
export async function apiPatch<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
const resp = await fetchWithAuth(path, buildOpts('PATCH', body, opts));
return unwrap<T>(resp, opts);
}
/** `DELETE <path>` → parsed JSON body of type `T` (often `void`/204). */
export async function apiDelete<T = void>(path: string, opts?: ApiRequestOpts): Promise<T> {
const resp = await fetchWithAuth(path, buildOpts('DELETE', undefined, opts));
return unwrap<T>(resp, opts);
}
+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": "音频处理",