Compare commits
3 Commits
v0.7.0
...
bb3a316e35
| Author | SHA1 | Date | |
|---|---|---|---|
| bb3a316e35 | |||
| 49c35a2ea0 | |||
| ef1f9eade2 |
+92
-11
@@ -18,6 +18,7 @@ context.
|
|||||||
| `05f73ee` | H6 (bindable extraction only) |
|
| `05f73ee` | H6 (bindable extraction only) |
|
||||||
| `3b8f00e` + `c1aa2eb` | C7 store-side |
|
| `3b8f00e` + `c1aa2eb` | C7 store-side |
|
||||||
| `2f15fbb` | H3 |
|
| `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
|
All commits have ≥1 code-review subagent pass with HIGH findings fixed
|
||||||
before commit. Tests pass on each commit; ruff clean; tsc + bundle build
|
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.
|
**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
|
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.
|
hand-rolled.
|
||||||
|
|
||||||
**Approach:** introduce a rule-type registry on the frontend matching
|
**Done:** the two remaining hand-rolled dispatch ladders were converted
|
||||||
the backend's `_RULE_HANDLERS` shape.
|
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
|
### 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
|
**Estimated scope:** half a session. Low impact since the parallel-change
|
||||||
problem is already fixed.
|
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`
|
**File:** every `static/js/features/*.ts`
|
||||||
|
|
||||||
`fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every
|
`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
|
**Done:** `static/js/core/api-client.ts` now provides typed
|
||||||
methods (`get`, `post`, `put`, `delete`) that handle auth, JSON parsing,
|
`apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` that wrap
|
||||||
error normalisation. Replace `fetchWithAuth` calls across features.
|
`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
|
#### M8 — Global `_cached*` `let` vars
|
||||||
|
|
||||||
@@ -262,7 +332,11 @@ always start before reading).
|
|||||||
|
|
||||||
### Other frontend (severity in main list above)
|
### 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)
|
- **H7** — `device-discovery.ts` 1745 LOC (couple with H4)
|
||||||
- **H8** — `automations.ts` 1410 LOC (mirror H2)
|
- **H8** — `automations.ts` 1410 LOC (mirror H2)
|
||||||
- **M7** — shared API client
|
- **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
|
Critical to have typescript-reviewer feedback + manual UI testing after
|
||||||
each split.
|
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)
|
### Session B — Device redesign (1-2 sessions)
|
||||||
|
|
||||||
Address H4 alone. Touches device storage + provider classes; needs a
|
Address H4 alone. Touches device storage + provider classes; needs a
|
||||||
|
|||||||
@@ -30,23 +30,39 @@ val ledgrabVersionCode: Int = run {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.ledgrab.android"
|
namespace = "com.ledgrab.android"
|
||||||
compileSdk = 34
|
// SDK 35 (Android 15) — required for Play Store from Aug 2025 onward.
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.ledgrab.android"
|
applicationId = "com.ledgrab.android"
|
||||||
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
|
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
|
// Derived from git commit count (or ANDROID_VERSION_CODE env var
|
||||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||||
// sideload updates silently refused to install.
|
// sideload updates silently refused to install.
|
||||||
versionCode = ledgrabVersionCode
|
versionCode = ledgrabVersionCode
|
||||||
versionName = "0.7.0"
|
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 {
|
ndk {
|
||||||
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
// arm64-v8a is the primary target (real TV hardware).
|
||||||
// emulators), x86 (legacy emulators). Wheels in android/wheels/
|
// x86_64/x86 cover emulators.
|
||||||
// must be kept in sync — see build-scripts/build-pydantic-core.sh.
|
// armeabi-v7a is opt-in: many pre-2018 Mecool/X96/H96 TV boxes
|
||||||
abiFilters += listOf("arm64-v8a", "x86_64", "x86")
|
// 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.
|
// Each split contains only one native ABI's shared libraries + wheels.
|
||||||
splits {
|
splits {
|
||||||
abi {
|
abi {
|
||||||
|
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
|
||||||
|
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
|
||||||
isEnable = true
|
isEnable = true
|
||||||
reset()
|
reset()
|
||||||
include("arm64-v8a", "x86_64", "x86")
|
include("arm64-v8a", "x86_64", "x86")
|
||||||
|
if (v7Wheel) include("armeabi-v7a")
|
||||||
isUniversalApk = true // also produce a fat APK for sideloading
|
isUniversalApk = true // also produce a fat APK for sideloading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,10 +115,21 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
signingConfig = if (hasCiSigning) {
|
// Refuse to silently sign release APKs with the debug
|
||||||
signingConfigs.getByName("release")
|
// keystore — that's how a debug-signed release accidentally
|
||||||
} else {
|
// ships. CI must provide all four signing env vars. If a
|
||||||
signingConfigs.getByName("debug")
|
// 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-service:2.8.7")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
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
|
// QR code generation for displaying server URL on TV
|
||||||
implementation("com.google.zxing:core:3.5.3")
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
||||||
|
|||||||
@@ -26,9 +26,15 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
<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 -->
|
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
@@ -60,27 +66,41 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".LedGrabApp"
|
android:name=".LedGrabApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:banner="@drawable/ic_launcher"
|
android:banner="@drawable/banner_tv"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:theme="@style/Theme.LedGrab">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.LedGrab.Splash">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<service
|
||||||
android:name=".CaptureService"
|
android:name=".CaptureService"
|
||||||
android:foregroundServiceType="mediaProjection"
|
android:foregroundServiceType="mediaProjection|specialUse"
|
||||||
android:exported="false" />
|
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).
|
<!-- Autostart — fires on device boot (and package replace).
|
||||||
On rooted devices, launches CaptureService directly so capture
|
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.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.media.projection.MediaProjection
|
import android.media.projection.MediaProjection
|
||||||
import android.media.projection.MediaProjectionManager
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -15,6 +16,7 @@ import android.util.DisplayMetrics
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -26,7 +28,13 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Foreground service that runs the Python LedGrab server and captures
|
* 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() {
|
class CaptureService : Service() {
|
||||||
|
|
||||||
@@ -92,15 +100,33 @@ class CaptureService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
// CRITICAL: startForeground must be called IMMEDIATELY —
|
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||||
// before any other work, especially before getMediaProjection().
|
|
||||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
// 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"
|
val url = "http://$localIp:$SERVER_PORT"
|
||||||
try {
|
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) {
|
} catch (e: Exception) {
|
||||||
// Most common cause: missing foregroundServiceType permission
|
// 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)
|
Log.e(TAG, "startForeground failed — service cannot run", e)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
@@ -109,8 +135,6 @@ class CaptureService : Service() {
|
|||||||
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
|
||||||
|
|
||||||
if (intent == null && !useRoot) {
|
if (intent == null && !useRoot) {
|
||||||
// MediaProjection mode can't recover from a redelivery —
|
// MediaProjection mode can't recover from a redelivery —
|
||||||
// the consent token in the original intent is single-use.
|
// 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
|
return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun apiKey(): String? =
|
||||||
|
(application as? LedGrabApp)?.apiKeyManager?.apiKey
|
||||||
|
|
||||||
private fun startRootCapture(url: String) {
|
private fun startRootCapture(url: String) {
|
||||||
val newBridge = PythonBridge(this).also { b ->
|
val newBridge = PythonBridge(this).also { b ->
|
||||||
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||||
b.startServer(SERVER_PORT)
|
b.startServer(SERVER_PORT, apiKey())
|
||||||
}
|
}
|
||||||
bridge = newBridge
|
bridge = newBridge
|
||||||
|
|
||||||
@@ -167,12 +194,21 @@ class CaptureService : Service() {
|
|||||||
* Replace the active root pipeline with a fresh instance, reusing
|
* Replace the active root pipeline with a fresh instance, reusing
|
||||||
* the existing Python bridge (no server restart). Returns true if
|
* the existing Python bridge (no server restart). Returns true if
|
||||||
* the new pipeline launched, false otherwise.
|
* 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 {
|
private fun restartRootPipeline(): Boolean {
|
||||||
val currentBridge = bridge ?: return false
|
val currentBridge = bridge ?: return false
|
||||||
val old = rootCapture
|
// Tear down the old instance first so we don't run two
|
||||||
rootCapture = null
|
// screenrecord processes simultaneously fighting for the GPU.
|
||||||
runCatching { old?.stop() }
|
rootCapture?.let { old ->
|
||||||
|
rootCapture = null
|
||||||
|
runCatching { old.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
val next = RootScreenrecord(
|
val next = RootScreenrecord(
|
||||||
bridge = currentBridge,
|
bridge = currentBridge,
|
||||||
@@ -180,11 +216,21 @@ class CaptureService : Service() {
|
|||||||
height = CAPTURE_HEIGHT,
|
height = CAPTURE_HEIGHT,
|
||||||
fps = CAPTURE_FPS,
|
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()) {
|
if (!next.start()) {
|
||||||
Log.e(TAG, "Root capture failed to restart")
|
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
|
return false
|
||||||
}
|
}
|
||||||
rootCapture = next
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +258,7 @@ class CaptureService : Service() {
|
|||||||
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
|
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
|
||||||
"restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS",
|
"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")
|
Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts")
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return@launch
|
return@launch
|
||||||
@@ -263,7 +309,6 @@ class CaptureService : Service() {
|
|||||||
val bounds = windowMetrics.bounds
|
val bounds = windowMetrics.bounds
|
||||||
widthPixels = bounds.width()
|
widthPixels = bounds.width()
|
||||||
heightPixels = bounds.height()
|
heightPixels = bounds.height()
|
||||||
// densityDpi is still needed for VirtualDisplay; read from resources.
|
|
||||||
densityDpi = resources.displayMetrics.densityDpi
|
densityDpi = resources.displayMetrics.densityDpi
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -276,7 +321,7 @@ class CaptureService : Service() {
|
|||||||
|
|
||||||
val newBridge = PythonBridge(this).also { b ->
|
val newBridge = PythonBridge(this).also { b ->
|
||||||
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||||
b.startServer(SERVER_PORT)
|
b.startServer(SERVER_PORT, apiKey())
|
||||||
}
|
}
|
||||||
bridge = newBridge
|
bridge = newBridge
|
||||||
|
|
||||||
@@ -323,10 +368,10 @@ class CaptureService : Service() {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"LedGrab Screen Capture",
|
getString(R.string.notification_channel_name),
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
).apply {
|
).apply {
|
||||||
description = "Shows while LedGrab is capturing the screen"
|
description = getString(R.string.notification_channel_description)
|
||||||
}
|
}
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
@@ -343,9 +388,14 @@ class CaptureService : Service() {
|
|||||||
PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("LedGrab Running")
|
.setContentTitle(getString(R.string.notification_title))
|
||||||
.setContentText("Web UI: $url")
|
.setContentText(getString(R.string.notification_text, url))
|
||||||
.setSmallIcon(R.drawable.ic_launcher)
|
// 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)
|
.setContentIntent(tapIntent)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ class LedGrabApp : Application() {
|
|||||||
var initError: Throwable? = null
|
var initError: Throwable? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Lazily-initialized API-key manager (see [ApiKeyManager]). */
|
||||||
|
val apiKeyManager: ApiKeyManager by lazy { ApiKeyManager(this) }
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
installCrashLogger()
|
installCrashLogger()
|
||||||
|
pruneOldCrashLogs()
|
||||||
try {
|
try {
|
||||||
if (!Python.isStarted()) {
|
if (!Python.isStarted()) {
|
||||||
Python.start(AndroidPlatform(this))
|
Python.start(AndroidPlatform(this))
|
||||||
@@ -47,6 +51,15 @@ class LedGrabApp : Application() {
|
|||||||
// Bind application context for the BLE bridge so Python can
|
// Bind application context for the BLE bridge so Python can
|
||||||
// scan and connect to BLE LED controllers.
|
// scan and connect to BLE LED controllers.
|
||||||
BleBridge.init(this)
|
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 {
|
companion object {
|
||||||
private const val TAG = "LedGrabApp"
|
private const val TAG = "LedGrabApp"
|
||||||
|
private const val MAX_CRASH_LOGS = 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.ledgrab.android
|
package com.ledgrab.android
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.animation.ObjectAnimator
|
||||||
|
import android.animation.ValueAnimator
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
@@ -13,12 +16,16 @@ import android.os.PowerManager
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewStub
|
||||||
|
import android.view.animation.AccelerateDecelerateInterpolator
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.CheckBox
|
import android.widget.CheckBox
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.app.Activity
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.google.zxing.BarcodeFormat
|
import com.google.zxing.BarcodeFormat
|
||||||
import com.google.zxing.qrcode.QRCodeWriter
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -46,25 +53,47 @@ class MainActivity : Activity() {
|
|||||||
private const val SERVER_PORT = 8080
|
private const val SERVER_PORT = 8080
|
||||||
private const val REQUEST_MEDIA_PROJECTION = 1001
|
private const val REQUEST_MEDIA_PROJECTION = 1001
|
||||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
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 stoppedPanel: View
|
||||||
private lateinit var runningPanel: View
|
|
||||||
private lateinit var statusText: TextView
|
private lateinit var statusText: TextView
|
||||||
private lateinit var urlText: TextView
|
|
||||||
private lateinit var qrImage: ImageView
|
|
||||||
private lateinit var toggleButton: Button
|
private lateinit var toggleButton: Button
|
||||||
private lateinit var stopButtonRunning: Button
|
|
||||||
private lateinit var versionText: TextView
|
private lateinit var versionText: TextView
|
||||||
private lateinit var autostartCheck: CheckBox
|
private lateinit var autostartCheck: CheckBox
|
||||||
private lateinit var autostartPrefs: AutostartPrefs
|
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?) {
|
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)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// Surface fatal Python init errors instead of crashing.
|
// Surface fatal Python init errors instead of crashing.
|
||||||
val initError = (application as? LedGrabApp)?.initError
|
val initError = (application as? LedGrabApp)?.initError
|
||||||
if (initError != null) {
|
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)
|
showFatalErrorScreen(initError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -72,39 +101,62 @@ class MainActivity : Activity() {
|
|||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
stoppedPanel = findViewById(R.id.stopped_panel)
|
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)
|
statusText = findViewById(R.id.status_text)
|
||||||
urlText = findViewById(R.id.url_text)
|
|
||||||
qrImage = findViewById(R.id.qr_image)
|
|
||||||
toggleButton = findViewById(R.id.toggle_button)
|
toggleButton = findViewById(R.id.toggle_button)
|
||||||
stopButtonRunning = findViewById(R.id.stop_button_running)
|
|
||||||
versionText = findViewById(R.id.version_text)
|
versionText = findViewById(R.id.version_text)
|
||||||
autostartCheck = findViewById(R.id.autostart_check)
|
autostartCheck = findViewById(R.id.autostart_check)
|
||||||
|
|
||||||
val versionName = packageManager
|
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
|
||||||
.getPackageInfo(packageName, 0).versionName
|
|
||||||
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
|
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
|
||||||
|
|
||||||
autostartPrefs = AutostartPrefs(this)
|
autostartPrefs = AutostartPrefs(this)
|
||||||
autostartCheck.isChecked = autostartPrefs.isEnabled
|
// Autostart only takes effect on rooted devices. Hide the
|
||||||
// Autostart only takes effect on rooted devices — grey it out
|
// checkbox entirely on unrooted hardware instead of showing a
|
||||||
// on unrooted hardware so users don't expect magic. Cheap probe
|
// disabled-but-visible control, which reads as broken UI from
|
||||||
// (file-existence only, no process spawn).
|
// across the room.
|
||||||
if (!Root.looksRooted()) {
|
if (Root.looksRooted()) {
|
||||||
autostartCheck.isEnabled = false
|
autostartCheck.visibility = View.VISIBLE
|
||||||
autostartCheck.text = getString(R.string.autostart_unavailable)
|
autostartCheck.isChecked = autostartPrefs.isEnabled
|
||||||
}
|
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
|
||||||
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
|
autostartPrefs.isEnabled = isChecked
|
||||||
autostartPrefs.isEnabled = isChecked
|
if (isChecked) ensureIgnoringBatteryOptimizations()
|
||||||
if (isChecked) ensureIgnoringBatteryOptimizations()
|
}
|
||||||
|
} else {
|
||||||
|
autostartCheck.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleButton.setOnClickListener { startCapture() }
|
toggleButton.setOnClickListener { startCapture() }
|
||||||
stopButtonRunning.setOnClickListener { stopCaptureService() }
|
|
||||||
|
|
||||||
updateUI()
|
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
|
* Decide whether to go through the MediaProjection consent flow or
|
||||||
* jump straight into root capture. Root check is fast but may block
|
* 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
|
* on the UI thread is acceptable because we're responding to a
|
||||||
* button press and we want to block until the user answers.
|
* button press and we want to block until the user answers.
|
||||||
*/
|
*/
|
||||||
override fun onDestroy() {
|
|
||||||
uiScope.cancel()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startCapture() {
|
private fun startCapture() {
|
||||||
// `su -c id` can block for seconds while Magisk shows its grant
|
// `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
|
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) {
|
uiScope.launch(Dispatchers.IO) {
|
||||||
val rooted = Root.requestGrant()
|
val rooted = Root.requestGrant()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
toggleButton.isEnabled = true
|
toggleButton.isEnabled = true
|
||||||
|
toggleButton.text = originalText
|
||||||
statusText.text = ""
|
statusText.text = ""
|
||||||
if (rooted) {
|
if (rooted) {
|
||||||
Log.i(TAG, "Root available — skipping MediaProjection consent")
|
Log.i(TAG, "Root available — skipping MediaProjection consent")
|
||||||
@@ -156,7 +207,7 @@ class MainActivity : Activity() {
|
|||||||
if (resultCode == RESULT_OK && data != null) {
|
if (resultCode == RESULT_OK && data != null) {
|
||||||
startCaptureService(resultCode, data)
|
startCaptureService(resultCode, data)
|
||||||
} else {
|
} else {
|
||||||
statusText.text = "Permission denied — screen capture requires authorization"
|
statusText.text = getString(R.string.status_permission_denied)
|
||||||
Log.w(TAG, "MediaProjection permission denied")
|
Log.w(TAG, "MediaProjection permission denied")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,42 +225,130 @@ class MainActivity : Activity() {
|
|||||||
updateUI()
|
updateUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateUI() {
|
private fun ensureRunningPanelInflated(): View {
|
||||||
if (CaptureService.isRunning) {
|
runningPanel?.let { return it }
|
||||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
val view = runningPanelStub.inflate()
|
||||||
val url = "http://$localIp:$SERVER_PORT"
|
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
|
private fun updateUI() {
|
||||||
qrImage.setImageBitmap(null)
|
// Fatal-init-error path took over setContentView and the
|
||||||
// Build the bitmap pixels off the Main thread — encode + 313k
|
// lateinit view fields are unassigned. Guard so any future
|
||||||
// setPixel calls were noticeably janky on slow TV boxes.
|
// caller (Resume, broadcast receiver, etc.) doesn't NPE.
|
||||||
uiScope.launch(Dispatchers.Default) {
|
if (!::stoppedPanel.isInitialized) return
|
||||||
val bitmap = generateQrCode(url)
|
if (CaptureService.isRunning) {
|
||||||
withContext(Dispatchers.Main) {
|
val running = ensureRunningPanelInflated()
|
||||||
if (CaptureService.isRunning && urlText.text == url) {
|
val localIp = NetworkUtils.getLocalIpAddress(this)
|
||||||
qrImage.setImageBitmap(bitmap)
|
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
|
stoppedPanel.visibility = View.GONE
|
||||||
versionText.visibility = View.GONE
|
versionText.visibility = View.GONE
|
||||||
runningPanel.visibility = View.VISIBLE
|
running.visibility = View.VISIBLE
|
||||||
stopButtonRunning.requestFocus()
|
stopButtonRunning?.requestFocus()
|
||||||
|
startStatusDotPulse()
|
||||||
} else {
|
} else {
|
||||||
urlText.text = ""
|
stopStatusDotPulse()
|
||||||
qrImage.setImageBitmap(null)
|
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
|
stoppedPanel.visibility = View.VISIBLE
|
||||||
versionText.visibility = View.VISIBLE
|
versionText.visibility = View.VISIBLE
|
||||||
toggleButton.requestFocus()
|
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 {
|
private fun generateQrCode(text: String): Bitmap {
|
||||||
val size = 560
|
val size = QR_SIZE_PX
|
||||||
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
|
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)
|
val pixels = IntArray(size * size)
|
||||||
for (y in 0 until size) {
|
for (y in 0 until size) {
|
||||||
val rowOffset = y * size
|
val rowOffset = y * size
|
||||||
@@ -218,34 +357,54 @@ class MainActivity : Activity() {
|
|||||||
if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
|
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)
|
bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
|
||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
|
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
|
||||||
* Rendered programmatically so we don't depend on the regular layout
|
* Stack trace is hidden behind a "Show details" toggle so we don't
|
||||||
* (which itself may reference resources affected by the failure).
|
* print user-path data on shared TV screens by default.
|
||||||
*/
|
*/
|
||||||
private fun showFatalErrorScreen(error: Throwable) {
|
private fun showFatalErrorScreen(error: Throwable) {
|
||||||
Log.e(TAG, "Fatal init error — showing error screen", error)
|
Log.e(TAG, "Fatal init error — showing error screen", error)
|
||||||
val stackText = android.util.Log.getStackTraceString(error)
|
val stackText = Log.getStackTraceString(error)
|
||||||
val container = android.widget.LinearLayout(this).apply {
|
val container = LinearLayout(this).apply {
|
||||||
orientation = android.widget.LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
setPadding(48, 48, 48, 48)
|
setPadding(48, 48, 48, 48)
|
||||||
}
|
}
|
||||||
val title = TextView(this).apply {
|
val title = TextView(this).apply {
|
||||||
text = "LedGrab failed to start"
|
text = getString(R.string.fatal_title)
|
||||||
textSize = 22f
|
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 {
|
val body = TextView(this).apply {
|
||||||
text = "Python runtime initialization failed:\n\n$stackText"
|
text = stackText
|
||||||
textSize = 12f
|
textSize = 12f
|
||||||
setTextIsSelectable(true)
|
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 {
|
val copyBtn = Button(this).apply {
|
||||||
text = "Copy log"
|
text = getString(R.string.fatal_copy_log)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
val cm = getSystemService(CLIPBOARD_SERVICE)
|
val cm = getSystemService(CLIPBOARD_SERVICE)
|
||||||
as android.content.ClipboardManager
|
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(title)
|
||||||
|
container.addView(description)
|
||||||
|
container.addView(toggleBtn)
|
||||||
container.addView(copyBtn)
|
container.addView(copyBtn)
|
||||||
container.addView(scroll)
|
container.addView(scroll)
|
||||||
setContentView(container)
|
setContentView(container)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt the user to exempt LedGrab from battery optimization. On
|
* Prompt the user to exempt LedGrab from battery optimization.
|
||||||
* TV boxes this is usually a no-op, but on phones Doze/App Standby
|
* Strictly a phone-side concern (Doze/App Standby kill the FG
|
||||||
* will kill the foreground service after a few hours of sleep. We
|
* service after hours of sleep); essentially a no-op on TV boxes.
|
||||||
* only ask when autostart is turned on. No-op on pre-M or when
|
* Only asked when autostart is turned on, which is itself only
|
||||||
* already exempt.
|
* available on rooted devices.
|
||||||
*
|
*
|
||||||
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
|
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
|
||||||
* — LedGrab's ambient-capture use case falls under the documented
|
* — LedGrab's ambient-capture use case falls under the documented
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.ledgrab.android
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.LinkProperties
|
import android.net.LinkProperties
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,18 +13,58 @@ import java.net.Inet4Address
|
|||||||
object NetworkUtils {
|
object NetworkUtils {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the device's local IPv4 address on the active network,
|
* Return the device's local IPv4 address, preferring (in order):
|
||||||
* or `null` if unavailable.
|
* - 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? {
|
fun getLocalIpAddress(context: Context): String? {
|
||||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
val network = cm.activeNetwork ?: return null
|
// TODO(AP-mode): On TV boxes acting as a Wi-Fi tether/hotspot,
|
||||||
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
|
// 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
|
return props.linkAddresses
|
||||||
|
.asSequence()
|
||||||
.map { it.address }
|
.map { it.address }
|
||||||
.filterIsInstance<Inet4Address>()
|
.filterIsInstance<Inet4Address>()
|
||||||
.firstOrNull { !it.isLoopbackAddress }
|
.firstOrNull { !it.isLoopbackAddress && !it.isLinkLocalAddress }
|
||||||
?.hostAddress
|
?.hostAddress
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import com.chaquo.python.Python
|
|||||||
* Bridge between Kotlin and the LedGrab Python server.
|
* Bridge between Kotlin and the LedGrab Python server.
|
||||||
*
|
*
|
||||||
* All Python calls go through Chaquopy's `Python.getInstance()`.
|
* 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) {
|
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.
|
* 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) {
|
if (running) {
|
||||||
Log.w(TAG, "Server already running")
|
Log.w(TAG, "Server already running")
|
||||||
return
|
return
|
||||||
@@ -71,7 +77,11 @@ class PythonBridge(private val context: Context) {
|
|||||||
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
|
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
|
||||||
val py = Python.getInstance()
|
val py = Python.getInstance()
|
||||||
val entry = py.getModule("ledgrab.android_entry")
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Python server error", e)
|
Log.e(TAG, "Python server error", e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -106,7 +116,8 @@ class PythonBridge(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* Called from [ScreenCapture] on the capture thread. The byte array
|
* Called from [ScreenCapture] on the capture thread. The byte array
|
||||||
* crosses the JNI boundary — keep frames small (downscale to 480p
|
* 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) {
|
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
|
||||||
if (!running) return
|
if (!running) return
|
||||||
|
|||||||
@@ -100,14 +100,41 @@ object Root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run an `su -c <cmd>` command. Returns true on exit-zero. Failure
|
* Run a command as root.
|
||||||
* invalidates the cached grant so the next [requestGrant] re-checks
|
*
|
||||||
* (covers cases like Magisk grant being revoked mid-session).
|
* 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
|
@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 {
|
return try {
|
||||||
val process = ProcessBuilder("su", "-c", cmd)
|
val process = ProcessBuilder("su", "-c", shellLine)
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start()
|
.start()
|
||||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||||
@@ -122,12 +149,34 @@ object Root {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}")
|
Log.w(TAG, "runAsRoot('$shellLine') failed: ${e.message}")
|
||||||
cachedGranted = null
|
cachedGranted = null
|
||||||
false
|
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. */
|
/** Forget the cached grant result — useful if Magisk permission was revoked. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun invalidateCache() {
|
fun invalidateCache() {
|
||||||
|
|||||||
@@ -38,8 +38,15 @@ class RootScreenrecord(
|
|||||||
private const val TAG = "RootScreenrecord"
|
private const val TAG = "RootScreenrecord"
|
||||||
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
|
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||||
private const val INPUT_CHUNK = 64 * 1024
|
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
|
@Volatile private var process: Process? = null
|
||||||
private var decoder: MediaCodec? = null
|
private var decoder: MediaCodec? = null
|
||||||
private var imageReader: ImageReader? = null
|
private var imageReader: ImageReader? = null
|
||||||
@@ -48,7 +55,22 @@ class RootScreenrecord(
|
|||||||
private var outputThread: Thread? = null
|
private var outputThread: Thread? = null
|
||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
private val framesDeliveredCounter = AtomicInteger(0)
|
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. */
|
/** Monotonic count of frames pushed to the Python bridge. */
|
||||||
val framesDelivered: Int get() = framesDeliveredCounter.get()
|
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
|
@Synchronized
|
||||||
fun stop() {
|
fun stop() {
|
||||||
if (stopped) return
|
if (disposed) return
|
||||||
stopped = true
|
disposed = true
|
||||||
// Order matters: signal first so worker loops drop out, then
|
// Order matters: signal first so worker loops drop out, then
|
||||||
// stop the codec on the thread that created it (this one), then
|
// stop the codec on the thread that created it (this one), then
|
||||||
// join workers BEFORE releasing the codec/ImageReader they may
|
// join workers BEFORE releasing the codec/ImageReader they may
|
||||||
@@ -107,7 +129,9 @@ class RootScreenrecord(
|
|||||||
// Best-effort: kill the screenrecord child before reaping `su`,
|
// Best-effort: kill the screenrecord child before reaping `su`,
|
||||||
// otherwise screenrecord can outlive su as an orphan and keep
|
// otherwise screenrecord can outlive su as an orphan and keep
|
||||||
// the GPU encoder busy. Fire-and-forget; ignore failures.
|
// 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() }
|
runCatching { decoder?.release() }
|
||||||
decoder = null
|
decoder = null
|
||||||
@@ -120,8 +144,13 @@ class RootScreenrecord(
|
|||||||
runCatching { readerThread?.join(500) }
|
runCatching { readerThread?.join(500) }
|
||||||
readerThread = null
|
readerThread = null
|
||||||
|
|
||||||
runCatching { process?.destroy() }
|
// Use the same lock as the respawn path so we don't destroy a
|
||||||
process = null
|
// 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})")
|
Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})")
|
||||||
}
|
}
|
||||||
@@ -131,7 +160,7 @@ class RootScreenrecord(
|
|||||||
readerThread = thread
|
readerThread = thread
|
||||||
val handler = Handler(thread.looper)
|
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 ->
|
reader.setOnImageAvailableListener({ r ->
|
||||||
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
|
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
|
||||||
try {
|
try {
|
||||||
@@ -139,19 +168,17 @@ class RootScreenrecord(
|
|||||||
val buffer = plane.buffer
|
val buffer = plane.buffer
|
||||||
val rowStride = plane.rowStride
|
val rowStride = plane.rowStride
|
||||||
val pixelStride = plane.pixelStride
|
val pixelStride = plane.pixelStride
|
||||||
val bytes = if (rowStride == width * pixelStride) {
|
val rowBytes = width * pixelStride
|
||||||
ByteArray(buffer.remaining()).also { buffer.get(it) }
|
val expected = rowBytes * height
|
||||||
|
if (rowStride == rowBytes && buffer.remaining() >= expected) {
|
||||||
|
buffer.get(frameBuffer, 0, expected)
|
||||||
} else {
|
} else {
|
||||||
// Strip row padding — common when width isn't a multiple of 16.
|
for (row in 0 until height) {
|
||||||
val rowBytes = width * pixelStride
|
buffer.position(row * rowStride)
|
||||||
ByteArray(width * height * 4).also { out ->
|
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||||
for (row in 0 until height) {
|
|
||||||
buffer.position(row * rowStride)
|
|
||||||
buffer.get(out, row * rowBytes, rowBytes)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bridge.pushRootFrame(bytes, width, height)
|
bridge.pushRootFrame(frameBuffer, width, height)
|
||||||
framesDeliveredCounter.incrementAndGet()
|
framesDeliveredCounter.incrementAndGet()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Root frame delivery failed: ${e.message}")
|
Log.w(TAG, "Root frame delivery failed: ${e.message}")
|
||||||
@@ -173,18 +200,26 @@ class RootScreenrecord(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun spawnScreenrecord(): Process? {
|
private fun spawnScreenrecord(): Process? {
|
||||||
val cmd = buildString {
|
// argv form — passes safely through Root.runAsRoot's shell-quote
|
||||||
append("screenrecord")
|
// logic so future changes to flag values can't introduce injection.
|
||||||
append(" --output-format=h264")
|
val args = arrayOf(
|
||||||
append(" --size=${width}x$height")
|
"screenrecord",
|
||||||
append(" --bit-rate=$bitRate")
|
"--output-format=h264",
|
||||||
|
"--size=${width}x$height",
|
||||||
|
"--bit-rate=$bitRate",
|
||||||
// Time limit 0 isn't supported; the largest accepted is 180s.
|
// Time limit 0 isn't supported; the largest accepted is 180s.
|
||||||
// We restart the process ourselves if it exits early.
|
// We restart the process ourselves if it exits early.
|
||||||
append(" --time-limit=180")
|
"--time-limit=180",
|
||||||
append(" -")
|
"-",
|
||||||
}
|
)
|
||||||
|
// 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 {
|
return try {
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
|
ProcessBuilder("su", "-c", args.joinToString(" "))
|
||||||
|
.redirectErrorStream(false)
|
||||||
|
.start()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
|
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
|
||||||
null
|
null
|
||||||
@@ -210,21 +245,56 @@ class RootScreenrecord(
|
|||||||
// exits cleanly we respawn so capture survives
|
// exits cleanly we respawn so capture survives
|
||||||
// long sessions instead of freezing after ~3min.
|
// long sessions instead of freezing after ~3min.
|
||||||
Log.i(TAG, "screenrecord EOF — respawning")
|
Log.i(TAG, "screenrecord EOF — respawning")
|
||||||
runCatching { process?.destroy() }
|
synchronized(processLock) {
|
||||||
|
runCatching { process?.destroy() }
|
||||||
|
process = null
|
||||||
|
}
|
||||||
val next = spawnScreenrecord()
|
val next = spawnScreenrecord()
|
||||||
if (next == null) {
|
if (next == null) {
|
||||||
// Avoid a tight loop if `su` is suddenly unhappy.
|
// Avoid a tight loop if `su` is suddenly unhappy.
|
||||||
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
|
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
|
||||||
continue@outer
|
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
|
stream = next.inputStream
|
||||||
continue@outer
|
continue@outer
|
||||||
}
|
}
|
||||||
var offset = 0
|
var offset = 0
|
||||||
while (offset < n && running) {
|
while (offset < n && running) {
|
||||||
val index = codec.dequeueInputBuffer(50_000)
|
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
|
val inputBuffer = codec.getInputBuffer(index) ?: continue
|
||||||
inputBuffer.clear()
|
inputBuffer.clear()
|
||||||
val chunk = minOf(n - offset, inputBuffer.capacity())
|
val chunk = minOf(n - offset, inputBuffer.capacity())
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.ledgrab.android
|
package com.ledgrab.android
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.hardware.display.VirtualDisplay
|
import android.hardware.display.VirtualDisplay
|
||||||
@@ -8,24 +7,26 @@ import android.media.ImageReader
|
|||||||
import android.media.projection.MediaProjection
|
import android.media.projection.MediaProjection
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.HandlerThread
|
import android.os.HandlerThread
|
||||||
|
import android.os.SystemClock
|
||||||
import android.util.DisplayMetrics
|
import android.util.DisplayMetrics
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.nio.ByteBuffer
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures the Android screen via MediaProjection and feeds frames
|
* Captures the Android screen via MediaProjection and feeds frames
|
||||||
* to [PythonBridge].
|
* to [PythonBridge].
|
||||||
*
|
*
|
||||||
* Frames are downscaled to [targetWidth] x [targetHeight] before
|
* Frames are downscaled to roughly [targetWidth] x [targetHeight] before
|
||||||
* crossing the JNI boundary to minimize overhead. For LED ambient
|
* crossing the JNI boundary to minimize overhead. The actual capture
|
||||||
* lighting, even 480x270 contains far more data than needed.
|
* 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(
|
class ScreenCapture(
|
||||||
private val projection: MediaProjection,
|
private val projection: MediaProjection,
|
||||||
private val metrics: DisplayMetrics,
|
private val metrics: DisplayMetrics,
|
||||||
private val bridge: PythonBridge,
|
private val bridge: PythonBridge,
|
||||||
private val targetWidth: Int = 480,
|
targetWidth: Int = 480,
|
||||||
private val targetHeight: Int = 270,
|
targetHeight: Int = 270,
|
||||||
private val targetFps: Int = 30,
|
private val targetFps: Int = 30,
|
||||||
private val onProjectionStopped: () -> Unit = {},
|
private val onProjectionStopped: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@@ -34,13 +35,51 @@ class ScreenCapture(
|
|||||||
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
|
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 virtualDisplay: VirtualDisplay? = null
|
||||||
private var imageReader: ImageReader? = null
|
private var imageReader: ImageReader? = null
|
||||||
private var captureThread: HandlerThread? = null
|
private var captureThread: HandlerThread? = null
|
||||||
private var captureHandler: Handler? = null
|
private var captureHandler: Handler? = null
|
||||||
@Volatile private var running = false
|
@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.
|
* Start capturing the screen.
|
||||||
@@ -48,6 +87,7 @@ class ScreenCapture(
|
|||||||
fun start() {
|
fun start() {
|
||||||
if (running) return
|
if (running) return
|
||||||
running = true
|
running = true
|
||||||
|
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
|
||||||
|
|
||||||
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
|
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
|
||||||
captureHandler = Handler(captureThread!!.looper)
|
captureHandler = Handler(captureThread!!.looper)
|
||||||
@@ -56,28 +96,32 @@ class ScreenCapture(
|
|||||||
projection.registerCallback(object : MediaProjection.Callback() {
|
projection.registerCallback(object : MediaProjection.Callback() {
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
Log.i(TAG, "MediaProjection stopped (external)")
|
Log.i(TAG, "MediaProjection stopped (external)")
|
||||||
stop()
|
// We're on captureHandler's thread here — calling stop()
|
||||||
// Notify the service so the foreground notification /
|
// directly would self-join captureThread (handler.join()
|
||||||
// Python server get torn down too — otherwise a stale
|
// from inside the handler thread hangs until the join
|
||||||
// "Running" notification lingers after the user taps
|
// timeout, then closes resources while we're STILL
|
||||||
// Android's system Cast/Screen-capture stop banner.
|
// 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()
|
onProjectionStopped()
|
||||||
}
|
}
|
||||||
}, captureHandler)
|
}, captureHandler)
|
||||||
|
|
||||||
imageReader = ImageReader.newInstance(
|
imageReader = ImageReader.newInstance(
|
||||||
targetWidth,
|
captureWidth,
|
||||||
targetHeight,
|
captureHeight,
|
||||||
PixelFormat.RGBA_8888,
|
PixelFormat.RGBA_8888,
|
||||||
2, // maxImages — double buffer
|
3, // maxImages — small ring buffer; 3 is more forgiving than 2 under jitter
|
||||||
)
|
)
|
||||||
|
|
||||||
imageReader?.setOnImageAvailableListener({ reader ->
|
imageReader?.setOnImageAvailableListener({ reader ->
|
||||||
if (!running) return@setOnImageAvailableListener
|
if (!running) return@setOnImageAvailableListener
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
val now = SystemClock.elapsedRealtimeNanos()
|
||||||
if (now - lastFrameTimeMs < frameIntervalMs) {
|
if (now < nextFrameNanos) {
|
||||||
// Skip frame to maintain target FPS
|
// Too early — drop this image to stay on cadence.
|
||||||
reader.acquireLatestImage()?.close()
|
reader.acquireLatestImage()?.close()
|
||||||
return@setOnImageAvailableListener
|
return@setOnImageAvailableListener
|
||||||
}
|
}
|
||||||
@@ -88,26 +132,30 @@ class ScreenCapture(
|
|||||||
val buffer = plane.buffer
|
val buffer = plane.buffer
|
||||||
val rowStride = plane.rowStride
|
val rowStride = plane.rowStride
|
||||||
val pixelStride = plane.pixelStride
|
val pixelStride = plane.pixelStride
|
||||||
|
val rowBytes = captureWidth * pixelStride
|
||||||
|
val expected = rowBytes * captureHeight
|
||||||
|
|
||||||
// Handle row padding: rowStride may be > width * pixelStride
|
// Fill the reusable buffer. Two paths:
|
||||||
val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
|
// - rowStride == rowBytes: bulk get into the buffer
|
||||||
// No padding — direct copy
|
// - rowStride > rowBytes: row-by-row copy stripping padding
|
||||||
val bytes = ByteArray(buffer.remaining())
|
if (rowStride == rowBytes && buffer.remaining() >= expected) {
|
||||||
buffer.get(bytes)
|
buffer.get(frameBuffer, 0, expected)
|
||||||
bytes
|
|
||||||
} else {
|
} else {
|
||||||
// Strip row padding
|
for (row in 0 until captureHeight) {
|
||||||
val rowBytes = targetWidth * pixelStride
|
|
||||||
val bytes = ByteArray(targetWidth * targetHeight * 4)
|
|
||||||
for (row in 0 until targetHeight) {
|
|
||||||
buffer.position(row * rowStride)
|
buffer.position(row * rowStride)
|
||||||
buffer.get(bytes, row * rowBytes, rowBytes)
|
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||||
}
|
}
|
||||||
bytes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
|
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
||||||
lastFrameTimeMs = now
|
|
||||||
|
// 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) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Frame processing error: ${e.message}")
|
Log.w(TAG, "Frame processing error: ${e.message}")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -117,8 +165,8 @@ class ScreenCapture(
|
|||||||
|
|
||||||
virtualDisplay = projection.createVirtualDisplay(
|
virtualDisplay = projection.createVirtualDisplay(
|
||||||
VIRTUAL_DISPLAY_NAME,
|
VIRTUAL_DISPLAY_NAME,
|
||||||
targetWidth,
|
captureWidth,
|
||||||
targetHeight,
|
captureHeight,
|
||||||
metrics.densityDpi,
|
metrics.densityDpi,
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||||
imageReader?.surface,
|
imageReader?.surface,
|
||||||
@@ -126,7 +174,7 @@ class ScreenCapture(
|
|||||||
captureHandler,
|
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.hardware.usb.UsbManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||||
import com.hoho.android.usbserial.driver.UsbSerialPort
|
import com.hoho.android.usbserial.driver.UsbSerialPort
|
||||||
import com.hoho.android.usbserial.driver.UsbSerialProber
|
import com.hoho.android.usbserial.driver.UsbSerialProber
|
||||||
@@ -54,8 +55,23 @@ object UsbSerialBridge {
|
|||||||
if (!initialized.compareAndSet(false, true)) return
|
if (!initialized.compareAndSet(false, true)) return
|
||||||
|
|
||||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||||
|
val ourPackage = app.packageName
|
||||||
val receiver = object : BroadcastReceiver() {
|
val receiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(ctx: Context, intent: Intent) {
|
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(
|
val granted = intent.getBooleanExtra(
|
||||||
UsbManager.EXTRA_PERMISSION_GRANTED,
|
UsbManager.EXTRA_PERMISSION_GRANTED,
|
||||||
false,
|
false,
|
||||||
@@ -69,13 +85,16 @@ object UsbSerialBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts.
|
// ContextCompat handles the RECEIVER_NOT_EXPORTED flag correctly
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
// across all supported API levels (it's a no-op on platforms
|
||||||
app.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
// where the flag doesn't exist, and explicit on API ≥33 where
|
||||||
} else {
|
// Android enforces it).
|
||||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
ContextCompat.registerReceiver(
|
||||||
app.registerReceiver(receiver, filter)
|
app,
|
||||||
}
|
receiver,
|
||||||
|
filter,
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ctx(): Context =
|
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"?>
|
<?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"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="oval">
|
android:shape="oval">
|
||||||
<solid android:color="@color/green_status" />
|
<solid android:color="@color/green_status" />
|
||||||
|
<size android:width="18dp" android:height="18dp" />
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome status-bar icon. Android requires white-on-transparent for
|
||||||
|
notification icons since API 21 - reusing the colored launcher would
|
||||||
|
render as a gray blob. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFFFF">
|
||||||
|
<!-- TV body -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M5,7 L19,7 Q20,7 20,8 L20,16 Q20,17 19,17 L5,17 Q4,17 4,16 L4,8 Q4,7 5,7 Z M5.5,8.5 L5.5,15.5 L18.5,15.5 L18.5,8.5 Z" />
|
||||||
|
<!-- TV stand -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M10,17 L10,18.5 L14,18.5 L14,17 Z M9,19 L15,19 L15,20 L9,20 Z" />
|
||||||
|
<!-- LED glow strips around the TV (bright dots) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M6,5.5 L18,5.5 L18,6.5 L6,6.5 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Splash screen icon (API 31+ uses a 1:1 vector inside a 240dp circle).
|
||||||
|
The SplashScreen API masks this with a circle automatically. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="240dp"
|
||||||
|
android:height="240dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- TV body -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1c2333"
|
||||||
|
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
|
||||||
|
<!-- TV screen -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#161b22"
|
||||||
|
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
|
||||||
|
<!-- LED glow strips, brighter on splash for impact -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#64ffda"
|
||||||
|
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#bb86fc"
|
||||||
|
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#ff6b6b"
|
||||||
|
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffd93d"
|
||||||
|
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
|
||||||
|
<!-- TV stand -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1c2333"
|
||||||
|
android:pathData="M44,72 L44,78 L64,78 L64,72" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1c2333"
|
||||||
|
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
|
||||||
|
</vector>
|
||||||
@@ -32,16 +32,28 @@
|
|||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:letterSpacing="0.08"
|
android:letterSpacing="0.08"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
android:fontFamily="sans-serif-light" />
|
android:fontFamily="sans-serif" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/status_text"
|
android:id="@+id/tagline_text"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/tagline"
|
android:text="@string/tagline"
|
||||||
android:textColor="@color/text_secondary"
|
android:textColor="@color/text_secondary"
|
||||||
android:textSize="28sp"
|
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
|
<Button
|
||||||
android:id="@+id/toggle_button"
|
android:id="@+id/toggle_button"
|
||||||
@@ -51,7 +63,8 @@
|
|||||||
android:text="@string/btn_start"
|
android:text="@string/btn_start"
|
||||||
android:textSize="22sp"
|
android:textSize="22sp"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:focusableInTouchMode="true" />
|
android:focusableInTouchMode="true"
|
||||||
|
android:nextFocusDown="@+id/autostart_check" />
|
||||||
|
|
||||||
<CheckBox
|
<CheckBox
|
||||||
android:id="@+id/autostart_check"
|
android:id="@+id/autostart_check"
|
||||||
@@ -63,10 +76,11 @@
|
|||||||
android:textSize="20sp"
|
android:textSize="20sp"
|
||||||
android:buttonTint="@color/teal_accent"
|
android:buttonTint="@color/teal_accent"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:focusableInTouchMode="true" />
|
android:focusableInTouchMode="true"
|
||||||
|
android:nextFocusUp="@id/toggle_button" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Version at bottom -->
|
<!-- Version at bottom (always visible — looks polished on TV idle). -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/version_text"
|
android:id="@+id/version_text"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -77,115 +91,13 @@
|
|||||||
android:textSize="18sp"
|
android:textSize="18sp"
|
||||||
tools:text="v0.1.0" />
|
tools:text="v0.1.0" />
|
||||||
|
|
||||||
<!-- RUNNING STATE -->
|
<!-- RUNNING STATE — deferred-inflate via ViewStub so first paint is
|
||||||
<LinearLayout
|
cheaper and the inflater doesn't measure two competing layouts. -->
|
||||||
android:id="@+id/running_panel"
|
<ViewStub
|
||||||
|
android:id="@+id/running_panel_stub"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="horizontal"
|
android:inflatedId="@+id/running_panel"
|
||||||
android:gravity="center_vertical"
|
android:layout="@layout/panel_running"
|
||||||
android:paddingStart="120dp"
|
android:visibility="gone" />
|
||||||
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>
|
|
||||||
</FrameLayout>
|
</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="app_name">LedGrab</string>
|
||||||
<string name="tagline">Фоновая подсветка для телевизора</string>
|
<string name="tagline">Фоновая подсветка для телевизора</string>
|
||||||
<string name="btn_start">Начать захват</string>
|
<string name="btn_start">Начать захват</string>
|
||||||
|
<string name="btn_starting">Запуск…</string>
|
||||||
<string name="btn_stop">Стоп</string>
|
<string name="btn_stop">Стоп</string>
|
||||||
<string name="status_running">Работает</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="label_web_ui">Адрес веб-интерфейса</string>
|
||||||
<string name="scan_to_configure">Сканируйте для настройки</string>
|
<string name="scan_to_configure">Сканируйте для настройки</string>
|
||||||
|
<string name="scan_fallback_hint">или откройте этот адрес с любого устройства в сети</string>
|
||||||
<string name="qr_description">QR-код для веб-интерфейса</string>
|
<string name="qr_description">QR-код для веб-интерфейса</string>
|
||||||
<string name="version_prefix">v%1$s</string>
|
<string name="version_prefix">v%1$s</string>
|
||||||
<string name="autostart_label">Запускать при загрузке (только с root)</string>
|
<string name="autostart_label">Запускать при загрузке (только с root)</string>
|
||||||
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен 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>
|
</resources>
|
||||||
|
|||||||
@@ -3,12 +3,26 @@
|
|||||||
<string name="app_name">LedGrab</string>
|
<string name="app_name">LedGrab</string>
|
||||||
<string name="tagline">电视氛围灯光</string>
|
<string name="tagline">电视氛围灯光</string>
|
||||||
<string name="btn_start">开始捕获</string>
|
<string name="btn_start">开始捕获</string>
|
||||||
|
<string name="btn_starting">正在启动…</string>
|
||||||
<string name="btn_stop">停止</string>
|
<string name="btn_stop">停止</string>
|
||||||
<string name="status_running">运行中</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="label_web_ui">Web界面地址</string>
|
||||||
<string name="scan_to_configure">扫码配置</string>
|
<string name="scan_to_configure">扫码配置</string>
|
||||||
|
<string name="scan_fallback_hint">或在同一网络的任何设备上访问上方网址</string>
|
||||||
<string name="qr_description">Web界面二维码</string>
|
<string name="qr_description">Web界面二维码</string>
|
||||||
<string name="version_prefix">v%1$s</string>
|
<string name="version_prefix">v%1$s</string>
|
||||||
<string name="autostart_label">开机自启(仅限 root)</string>
|
<string name="autostart_label">开机自启(仅限 root)</string>
|
||||||
<string name="autostart_unavailable">开机自启 — 不可用(需要 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>
|
</resources>
|
||||||
|
|||||||
@@ -3,12 +3,26 @@
|
|||||||
<string name="app_name">LedGrab</string>
|
<string name="app_name">LedGrab</string>
|
||||||
<string name="tagline">Ambient lighting for your TV</string>
|
<string name="tagline">Ambient lighting for your TV</string>
|
||||||
<string name="btn_start">Start Capture</string>
|
<string name="btn_start">Start Capture</string>
|
||||||
|
<string name="btn_starting">Starting…</string>
|
||||||
<string name="btn_stop">Stop</string>
|
<string name="btn_stop">Stop</string>
|
||||||
<string name="status_running">Running</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="label_web_ui">Web UI address</string>
|
||||||
<string name="scan_to_configure">Scan to configure</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="qr_description">QR code for web UI</string>
|
||||||
<string name="version_prefix">v%1$s</string>
|
<string name="version_prefix">v%1$s</string>
|
||||||
<string name="autostart_label">Start on boot (root only)</string>
|
<string name="autostart_label">Start on boot (root only)</string>
|
||||||
<string name="autostart_unavailable">Start on boot — unavailable (root required)</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>
|
</resources>
|
||||||
|
|||||||
@@ -12,6 +12,16 @@
|
|||||||
<item name="android:colorControlActivated">@color/teal_accent</item>
|
<item name="android:colorControlActivated">@color/teal_accent</item>
|
||||||
</style>
|
</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">
|
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
|
||||||
<item name="android:background">@drawable/bg_button_primary</item>
|
<item name="android:background">@drawable/bg_button_primary</item>
|
||||||
<item name="android:textColor">@color/bg_navy</item>
|
<item name="android:textColor">@color/bg_navy</item>
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
|
LedGrab is a LAN-only app:
|
||||||
brokers on the local network via plain HTTP/UDP. Cleartext traffic
|
- Inbound: web UI / API on the device (HTTP, port 8080)
|
||||||
must be allowed for these connections to work on Android 9+.
|
- 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>
|
<network-security-config>
|
||||||
<base-config cleartextTrafficPermitted="true" />
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Cross-compile pydantic-core for Android across all three ABIs:
|
# Cross-compile pydantic-core for Android across all supported ABIs:
|
||||||
# arm64-v8a (primary — real TV hardware)
|
# arm64-v8a (primary — modern TV hardware)
|
||||||
# x86_64 (modern emulators)
|
# x86_64 (modern emulators)
|
||||||
# x86 (legacy 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
|
# Outputs wheels into android/wheels/. Wheels are linked against the real
|
||||||
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
|
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
|
||||||
# memory/project_android_app.md for the incident notes).
|
# memory/project_android_app.md for the incident notes).
|
||||||
#
|
#
|
||||||
# Prerequisites (on host):
|
# 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/*)
|
# - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*)
|
||||||
# - Python 3.11 (matches Chaquopy's embedded version)
|
# - Python 3.11 (matches Chaquopy's embedded version)
|
||||||
# - maturin (pip install maturin)
|
# - maturin (pip install maturin)
|
||||||
@@ -19,9 +21,10 @@
|
|||||||
# core dependency version changes.
|
# core dependency version changes.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./build-pydantic-core.sh # build all three ABIs
|
# ./build-pydantic-core.sh # build all 4 ABIs
|
||||||
# ./build-pydantic-core.sh arm64 # build a single ABI
|
# ./build-pydantic-core.sh arm64 # build a single ABI
|
||||||
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
|
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
|
||||||
|
# ./build-pydantic-core.sh armv7 # 32-bit ARM only
|
||||||
#
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -91,21 +94,23 @@ fi
|
|||||||
# ── ABI table ───────────────────────────────────────────────────────
|
# ── ABI table ───────────────────────────────────────────────────────
|
||||||
# Columns: short_name rust_target clang_prefix sysconfig_dir
|
# Columns: short_name rust_target clang_prefix sysconfig_dir
|
||||||
ABI_TABLE=(
|
ABI_TABLE=(
|
||||||
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
|
"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_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"
|
"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=(
|
declare -A ABI_TAG_MAP=(
|
||||||
[arm64]="arm64_v8a"
|
[arm64]="arm64_v8a"
|
||||||
[x86_64]="x86_64"
|
[x86_64]="x86_64"
|
||||||
[x86]="x86"
|
[x86]="x86"
|
||||||
|
[armv7]="armeabi_v7a"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Select which ABIs to build ──────────────────────────────────────
|
# ── Select which ABIs to build ──────────────────────────────────────
|
||||||
SELECTED=("$@")
|
SELECTED=("$@")
|
||||||
if [ ${#SELECTED[@]} -eq 0 ]; then
|
if [ ${#SELECTED[@]} -eq 0 ]; then
|
||||||
SELECTED=(arm64 x86_64 x86)
|
SELECTED=(arm64 x86_64 x86 armv7)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Ensure rust targets are installed ───────────────────────────────
|
# ── Ensure rust targets are installed ───────────────────────────────
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ inside an Android application. Sets up Android-specific paths
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -15,7 +16,7 @@ _server: Any | None = None # uvicorn.Server
|
|||||||
_loop: asyncio.AbstractEventLoop | None = None
|
_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.
|
"""Start the LedGrab uvicorn server.
|
||||||
|
|
||||||
Called from Kotlin's ``PythonBridge.startServer()``. This function
|
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
|
data_dir: Android app-private files directory
|
||||||
(e.g. ``/data/data/com.ledgrab.android/files``).
|
(e.g. ``/data/data/com.ledgrab.android/files``).
|
||||||
port: HTTP port for the web UI / API.
|
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 ──────────────
|
# ── Configure paths before any LedGrab imports ──────────────
|
||||||
os.makedirs(os.path.join(data_dir, "data"), exist_ok=True)
|
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__HOST"] = "0.0.0.0"
|
||||||
os.environ["LEDGRAB_SERVER__PORT"] = str(port)
|
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 ──────────────────────────────
|
# ── Now safe to import LedGrab ──────────────────────────────
|
||||||
import uvicorn # noqa: E402
|
import uvicorn # noqa: E402
|
||||||
|
|
||||||
@@ -50,10 +64,25 @@ def start_server(data_dir: str, port: int = 8080) -> None:
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
logger.info("LedGrab Android: starting server on port %d", port)
|
logger.info("LedGrab Android: starting server on port %d", port)
|
||||||
logger.info("Data directory: %s", data_dir)
|
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
|
from ledgrab.config import get_config # noqa: E402
|
||||||
|
|
||||||
config = get_config()
|
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(
|
uv_config = uvicorn.Config(
|
||||||
"ledgrab.main:app",
|
"ledgrab.main:app",
|
||||||
|
|||||||
@@ -244,6 +244,30 @@ import {
|
|||||||
initDonationBanner, dismissDonation, snoozeDonation, renderAboutPanel, setProjectUrls,
|
initDonationBanner, dismissDonation, snoozeDonation, renderAboutPanel, setProjectUrls,
|
||||||
} from './features/donation.ts';
|
} 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 ───
|
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||||
|
|
||||||
Object.assign(window, {
|
Object.assign(window, {
|
||||||
@@ -724,6 +748,21 @@ window.addEventListener('beforeunload', () => {
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
try {
|
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
|
// Load API key from localStorage before anything that triggers API calls
|
||||||
setApiKey(localStorage.getItem('ledgrab_api_key'));
|
setApiKey(localStorage.getItem('ledgrab_api_key'));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Typed REST client — one place for the request/parse/error-unwrap dance
|
||||||
|
* that every feature module used to hand-roll on top of `fetchWithAuth`.
|
||||||
|
*
|
||||||
|
* Before this module, ~25 feature files repeated the same shape:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const resp = await fetchWithAuth(url, { method, body: JSON.stringify(p) });
|
||||||
|
* if (!resp.ok) {
|
||||||
|
* const err = await resp.json().catch(() => ({}));
|
||||||
|
* throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
* }
|
||||||
|
* const data = await resp.json();
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `apiGet` / `apiPost` / `apiPut` / `apiDelete` collapse that to a single
|
||||||
|
* call that returns the parsed body (typed via the caller's `<T>`) and
|
||||||
|
* throws an {@link ApiError} carrying the server's `detail` on failure.
|
||||||
|
*
|
||||||
|
* Auth headers, the 401 → re-login flow, timeouts, the 5xx/network retry
|
||||||
|
* loop, and the offline-toast are all still owned by `fetchWithAuth`; this
|
||||||
|
* is a thin typed layer on top, not a replacement.
|
||||||
|
*
|
||||||
|
* Audit finding M7.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth, ApiError } from './api.ts';
|
||||||
|
|
||||||
|
export interface ApiRequestOpts {
|
||||||
|
/**
|
||||||
|
* Message for the thrown {@link ApiError} when the server returns a
|
||||||
|
* non-2xx status *and* provides no usable `detail`. Defaults to
|
||||||
|
* `HTTP <status>`. Pass a localised string (e.g. `t('foo.error.save')`)
|
||||||
|
* to preserve the bespoke per-feature messages.
|
||||||
|
*/
|
||||||
|
errorMessage?: string;
|
||||||
|
/** Abort signal forwarded to `fetchWithAuth`. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/** Per-request timeout in ms (default 10 000, owned by `fetchWithAuth`). */
|
||||||
|
timeout?: number;
|
||||||
|
/** Disable the 5xx/network auto-retry loop (default: enabled). */
|
||||||
|
retry?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a `Response` into a parsed body or throw {@link ApiError}.
|
||||||
|
*
|
||||||
|
* `detail` handling mirrors — and slightly hardens — the old hand-rolled
|
||||||
|
* pattern: a string `detail` is used verbatim; FastAPI validation errors
|
||||||
|
* (an array of `{msg, ...}`) are joined instead of stringifying to
|
||||||
|
* `[object Object]`; otherwise we fall back to `errorMessage` then
|
||||||
|
* `HTTP <status>`.
|
||||||
|
*/
|
||||||
|
async function unwrap<T>(resp: Response, opts?: ApiRequestOpts): Promise<T> {
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.json().catch(() => ({} as Record<string, unknown>));
|
||||||
|
const detail = (body as { detail?: unknown }).detail;
|
||||||
|
let message: string;
|
||||||
|
if (typeof detail === 'string' && detail) {
|
||||||
|
message = detail;
|
||||||
|
} else if (Array.isArray(detail) && detail.length > 0) {
|
||||||
|
message = detail
|
||||||
|
.map((d) => (d && typeof d === 'object' && 'msg' in d ? String((d as { msg: unknown }).msg) : String(d)))
|
||||||
|
.join('; ');
|
||||||
|
} else {
|
||||||
|
message = opts?.errorMessage || `HTTP ${resp.status}`;
|
||||||
|
}
|
||||||
|
throw new ApiError(resp.status, message);
|
||||||
|
}
|
||||||
|
// 204 No Content (and other empty bodies) — nothing to parse.
|
||||||
|
if (resp.status === 204) return undefined as T;
|
||||||
|
const text = await resp.text();
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpts(method: string, body: unknown, opts?: ApiRequestOpts): RequestInit & { retry?: boolean; timeout?: number } {
|
||||||
|
const init: RequestInit & { retry?: boolean; timeout?: number } = { method };
|
||||||
|
if (body !== undefined) init.body = JSON.stringify(body);
|
||||||
|
if (opts?.signal) init.signal = opts.signal;
|
||||||
|
if (opts?.timeout !== undefined) init.timeout = opts.timeout;
|
||||||
|
if (opts?.retry !== undefined) init.retry = opts.retry;
|
||||||
|
return init;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET <path>` → parsed JSON body of type `T`. */
|
||||||
|
export async function apiGet<T>(path: string, opts?: ApiRequestOpts): Promise<T> {
|
||||||
|
const resp = await fetchWithAuth(path, buildOpts('GET', undefined, opts));
|
||||||
|
return unwrap<T>(resp, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST <path>` with a JSON body → parsed JSON body of type `T`. */
|
||||||
|
export async function apiPost<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
|
||||||
|
const resp = await fetchWithAuth(path, buildOpts('POST', body, opts));
|
||||||
|
return unwrap<T>(resp, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PUT <path>` with a JSON body → parsed JSON body of type `T`. */
|
||||||
|
export async function apiPut<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
|
||||||
|
const resp = await fetchWithAuth(path, buildOpts('PUT', body, opts));
|
||||||
|
return unwrap<T>(resp, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PATCH <path>` with a JSON body → parsed JSON body of type `T`. */
|
||||||
|
export async function apiPatch<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
|
||||||
|
const resp = await fetchWithAuth(path, buildOpts('PATCH', body, opts));
|
||||||
|
return unwrap<T>(resp, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE <path>` → parsed JSON body of type `T` (often `void`/204). */
|
||||||
|
export async function apiDelete<T = void>(path: string, opts?: ApiRequestOpts): Promise<T> {
|
||||||
|
const resp = await fetchWithAuth(path, buildOpts('DELETE', undefined, opts));
|
||||||
|
return unwrap<T>(resp, opts);
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
* Reusable data cache with fetch deduplication, invalidation, and subscribers.
|
* 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
|
// Server JSON is treated as `any` at the cache boundary because each
|
||||||
// extractor knows the endpoint-specific shape (e.g. `json.devices`).
|
// extractor knows the endpoint-specific shape (e.g. `json.devices`).
|
||||||
@@ -66,19 +67,18 @@ export class DataCache<T = unknown> {
|
|||||||
|
|
||||||
async _doFetch(): Promise<T> {
|
async _doFetch(): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(this._endpoint);
|
const json = await apiGet<any>(this._endpoint);
|
||||||
if (!resp.ok) {
|
|
||||||
console.error(`[DataCache] ${this._endpoint}: HTTP ${resp.status}`);
|
|
||||||
return this._data;
|
|
||||||
}
|
|
||||||
const json = await resp.json();
|
|
||||||
this._data = this._extractData(json);
|
this._data = this._extractData(json);
|
||||||
this._fresh = true;
|
this._fresh = true;
|
||||||
this._notify();
|
this._notify();
|
||||||
return this._data;
|
return this._data;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError && err.isAuth) return this._data;
|
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;
|
return this._data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* Command Palette — global search & navigation (Ctrl+K / Cmd+K).
|
* 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 { t } from './i18n.ts';
|
||||||
import { navigateToCard } from './navigation.ts';
|
import { navigateToCard } from './navigation.ts';
|
||||||
import {
|
import {
|
||||||
@@ -73,18 +74,18 @@ function _buildItems(results: any[], states: any = {}) {
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
const isRunning = actionItem._running;
|
const isRunning = actionItem._running;
|
||||||
const endpoint = isRunning ? 'stop' : 'start';
|
const endpoint = isRunning ? 'stop' : 'start';
|
||||||
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/${endpoint}`, { method: 'POST' });
|
try {
|
||||||
if (resp.ok) {
|
await apiPost(`/output-targets/${tgt.id}/${endpoint}`, undefined, {
|
||||||
|
errorMessage: t(`target.error.${endpoint}_failed`),
|
||||||
|
});
|
||||||
showToast(t(isRunning ? 'device.stopped' : 'device.started'), 'success');
|
showToast(t(isRunning ? 'device.stopped' : 'device.started'), 'success');
|
||||||
actionItem._running = !isRunning;
|
actionItem._running = !isRunning;
|
||||||
actionItem.detail = !isRunning ? t('search.action.stop') : t('search.action.start');
|
actionItem.detail = !isRunning ? t('search.action.stop') : t('search.action.start');
|
||||||
actionItem.icon = !isRunning ? '■' : '▶';
|
actionItem.icon = !isRunning ? '■' : '▶';
|
||||||
_render();
|
_render();
|
||||||
} else {
|
} catch (e: any) {
|
||||||
const err = await resp.json().catch(() => ({}));
|
if (e.isAuth) return;
|
||||||
const d = err.detail || err.message || '';
|
showToast(e.message || t(`target.error.${endpoint}_failed`), 'error');
|
||||||
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
|
|
||||||
showToast(ds || t(`target.error.${endpoint}_failed`), 'error');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -108,17 +109,17 @@ function _buildItems(results: any[], states: any = {}) {
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
const isEnabled = autoItem._enabled;
|
const isEnabled = autoItem._enabled;
|
||||||
const endpoint = isEnabled ? 'disable' : 'enable';
|
const endpoint = isEnabled ? 'disable' : 'enable';
|
||||||
const resp = await fetchWithAuth(`/automations/${a.id}/${endpoint}`, { method: 'POST' });
|
try {
|
||||||
if (resp.ok) {
|
await apiPost(`/automations/${a.id}/${endpoint}`, undefined, {
|
||||||
|
errorMessage: t('search.action.' + endpoint) + ' failed',
|
||||||
|
});
|
||||||
showToast(t('search.action.' + endpoint) + ': ' + a.name, 'success');
|
showToast(t('search.action.' + endpoint) + ': ' + a.name, 'success');
|
||||||
autoItem._enabled = !isEnabled;
|
autoItem._enabled = !isEnabled;
|
||||||
autoItem.detail = !isEnabled ? t('search.action.disable') : t('search.action.enable');
|
autoItem.detail = !isEnabled ? t('search.action.disable') : t('search.action.enable');
|
||||||
_render();
|
_render();
|
||||||
} else {
|
} catch (e: any) {
|
||||||
const err = await resp.json().catch(() => ({}));
|
if (e.isAuth) return;
|
||||||
const d = err.detail || err.message || '';
|
showToast(e.message || (t('search.action.' + endpoint) + ' failed'), 'error');
|
||||||
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
|
|
||||||
showToast(ds || (t('search.action.' + endpoint) + ' failed'), 'error');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -170,9 +171,15 @@ function _buildItems(results: any[], states: any = {}) {
|
|||||||
items.push({
|
items.push({
|
||||||
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
|
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' });
|
try {
|
||||||
if (resp.ok) { showToast(t('scenes.activated'), 'success'); }
|
await apiPost(`/scene-presets/${sp.id}/activate`, undefined, {
|
||||||
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'); }
|
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() {
|
async function _fetchAllEntities() {
|
||||||
const [statesData, ...results] = await Promise.all([
|
const [statesData, ...results] = await Promise.all([
|
||||||
fetchWithAuth('/output-targets/batch/states', { retry: false, timeout: 5000 })
|
apiGet<{ states?: any }>('/output-targets/batch/states', { retry: false, timeout: 5000 })
|
||||||
.then(r => r.ok ? r.json() : {})
|
.then((data) => data.states || {})
|
||||||
.then((data: any) => data.states || {})
|
|
||||||
.catch(() => ({})),
|
.catch(() => ({})),
|
||||||
..._responseKeys.map(([ep, key]) =>
|
..._responseKeys.map(([ep, key]) =>
|
||||||
fetchWithAuth(ep as string, { retry: false, timeout: 5000 })
|
apiGet<any>(ep as string, { retry: false, timeout: 5000 })
|
||||||
.then((r: any) => r.ok ? r.json() : {})
|
.then((data) => data[key as string] || [])
|
||||||
.then((data: any) => data[key as string] || [])
|
|
||||||
.catch((): any[] => [])),
|
.catch((): any[] => [])),
|
||||||
]);
|
]);
|
||||||
return _buildItems(results, statesData);
|
return _buildItems(results, statesData);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Supports creating, changing, and detaching connections via the graph editor.
|
* Supports creating, changing, and detaching connections via the graph editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchWithAuth } from './api.ts';
|
import { apiPut } from './api-client.ts';
|
||||||
import {
|
import {
|
||||||
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
||||||
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
||||||
@@ -151,11 +151,7 @@ export async function updateConnection(targetId: string, targetKind: string, fie
|
|||||||
const body = { [field]: newSourceId };
|
const body = { [field]: newSourceId };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(url, {
|
await apiPut(url, body);
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!resp.ok) return false;
|
|
||||||
// Invalidate the relevant cache so data refreshes
|
// Invalidate the relevant cache so data refreshes
|
||||||
if (entry.cache) entry.cache.invalidate();
|
if (entry.cache) entry.cache.invalidate();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
* attachProcessPicker(container, textarea);
|
* 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 { t } from './i18n.ts';
|
||||||
import { ICON_SEARCH } from './icons.ts';
|
import { ICON_SEARCH } from './icons.ts';
|
||||||
|
|
||||||
@@ -241,16 +242,21 @@ class NamePalette {
|
|||||||
/* ─── fetch helpers ────────────────────────────────────────── */
|
/* ─── fetch helpers ────────────────────────────────────────── */
|
||||||
|
|
||||||
async function _fetchProcesses(): Promise<string[]> {
|
async function _fetchProcesses(): Promise<string[]> {
|
||||||
const resp = await fetchWithAuth('/system/processes');
|
try {
|
||||||
if (!resp || !resp.ok) return [];
|
const data = await apiGet<{ processes?: string[] }>('/system/processes');
|
||||||
const data = await resp.json();
|
return data.processes || [];
|
||||||
return data.processes || [];
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _fetchNotificationApps(): Promise<string[]> {
|
async function _fetchNotificationApps(): Promise<string[]> {
|
||||||
const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history');
|
let data: { history?: any[] };
|
||||||
if (!resp || !resp.ok) return [];
|
try {
|
||||||
const data = await resp.json();
|
data = await apiGet<{ history?: any[] }>('/color-strip-sources/os-notifications/history');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const history: any[] = data.history || [];
|
const history: any[] = data.history || [];
|
||||||
// Deduplicate app names, preserving original case of first occurrence
|
// Deduplicate app names, preserving original case of first occurrence
|
||||||
const seen = new Map<string, string>();
|
const seen = new Map<string, string>();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* Tags are stored lowercase, trimmed, deduplicated.
|
* Tags are stored lowercase, trimmed, deduplicated.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchWithAuth } from './api.ts';
|
import { apiGet } from './api-client.ts';
|
||||||
|
|
||||||
let _allTagsCache: string[] | null = null;
|
let _allTagsCache: string[] | null = null;
|
||||||
let _allTagsFetchPromise: Promise<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[]> {
|
export async function fetchAllTags(): Promise<string[]> {
|
||||||
if (_allTagsCache) return _allTagsCache;
|
if (_allTagsCache) return _allTagsCache;
|
||||||
if (_allTagsFetchPromise) return _allTagsFetchPromise;
|
if (_allTagsFetchPromise) return _allTagsFetchPromise;
|
||||||
_allTagsFetchPromise = fetchWithAuth('/tags')
|
_allTagsFetchPromise = apiGet<{ tags?: string[] }>('/tags')
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
_allTagsCache = data.tags || [];
|
_allTagsCache = data.tags || [];
|
||||||
_allTagsFetchPromise = null;
|
_allTagsFetchPromise = null;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* The canvas shows monitor rectangles that can be repositioned for visual clarity.
|
* 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 { colorStripSourcesCache } from '../core/state.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast } from '../core/ui.ts';
|
import { showToast } from '../core/ui.ts';
|
||||||
@@ -137,14 +138,14 @@ const _modal = new AdvancedCalibrationModal();
|
|||||||
|
|
||||||
export async function showAdvancedCalibration(cssId: string): Promise<void> {
|
export async function showAdvancedCalibration(cssId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const [cssSources, psResp] = await Promise.all([
|
const [cssSources, psData] = await Promise.all([
|
||||||
colorStripSourcesCache.fetch(),
|
colorStripSourcesCache.fetch(),
|
||||||
fetchWithAuth('/picture-sources'),
|
apiGet<{ streams?: PictureSource[] }>('/picture-sources').catch((): { streams?: PictureSource[] } => ({})),
|
||||||
]);
|
]);
|
||||||
const source = cssSources.find(s => s.id === cssId);
|
const source = cssSources.find(s => s.id === cssId);
|
||||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||||
const calibration: Calibration = source.calibration || {} as Calibration;
|
const calibration: Calibration = source.calibration || {} as Calibration;
|
||||||
const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
|
const psList = psData.streams || [];
|
||||||
|
|
||||||
_state.cssId = cssId;
|
_state.cssId = cssId;
|
||||||
_state.sourceType = source.source_type || 'picture_advanced';
|
_state.sourceType = source.source_type || 'picture_advanced';
|
||||||
@@ -223,22 +224,13 @@ export async function saveAdvancedCalibration(): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
await apiPut(`/color-strip-sources/${cssId}`, { source_type: _state.sourceType, calibration }, {
|
||||||
method: 'PUT',
|
errorMessage: t('calibration.error.save_failed'),
|
||||||
body: JSON.stringify({ source_type: _state.sourceType, calibration }),
|
|
||||||
});
|
});
|
||||||
|
showToast(t('calibration.saved'), 'success');
|
||||||
if (resp.ok) {
|
colorStripSourcesCache.invalidate();
|
||||||
showToast(t('calibration.saved'), 'success');
|
_modal.forceClose();
|
||||||
colorStripSourcesCache.invalidate();
|
} catch (error: any) {
|
||||||
_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) {
|
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
showToast(error.message || t('calibration.error.save_failed'), 'error');
|
showToast(error.message || t('calibration.error.save_failed'), 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
_cachedAudioFilterDefs,
|
_cachedAudioFilterDefs,
|
||||||
audioFilterDefsCache,
|
audioFilterDefsCache,
|
||||||
} from '../core/state.ts';
|
} 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 { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.ts';
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
@@ -158,9 +159,7 @@ export async function editAudioProcessingTemplate(templateId: string) {
|
|||||||
try {
|
try {
|
||||||
if (_cachedAudioFilterDefs.length === 0) await audioFilterDefsCache.fetch();
|
if (_cachedAudioFilterDefs.length === 0) await audioFilterDefsCache.fetch();
|
||||||
|
|
||||||
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`);
|
const tmpl = await apiGet<any>(`/audio-processing-templates/${templateId}`);
|
||||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
|
||||||
const tmpl = await response.json();
|
|
||||||
|
|
||||||
document.getElementById('apt-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_processing.edit')}`;
|
document.getElementById('apt-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_processing.edit')}`;
|
||||||
(document.getElementById('apt-id') as HTMLInputElement).value = templateId;
|
(document.getElementById('apt-id') as HTMLInputElement).value = templateId;
|
||||||
@@ -212,13 +211,10 @@ export async function saveAudioProcessingTemplate() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = templateId ? `/audio-processing-templates/${templateId}` : '/audio-processing-templates';
|
if (templateId) {
|
||||||
const method = templateId ? 'PUT' : 'POST';
|
await apiPut(`/audio-processing-templates/${templateId}`, payload, { errorMessage: t('audio_processing.error.save_failed') });
|
||||||
const response = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
} else {
|
||||||
|
await apiPost('/audio-processing-templates', payload, { errorMessage: t('audio_processing.error.save_failed') });
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || error.message || 'Failed to save template');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(templateId ? t('audio_processing.updated') : t('audio_processing.created'), 'success');
|
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) {
|
export async function cloneAudioProcessingTemplate(templateId: string) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/audio-processing-templates/${templateId}`);
|
const tmpl = await apiGet<any>(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.load') });
|
||||||
if (!resp.ok) throw new Error('Failed to load template');
|
|
||||||
const tmpl = await resp.json();
|
|
||||||
await showAudioProcessingTemplateModal(tmpl);
|
await showAudioProcessingTemplateModal(tmpl);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
@@ -252,11 +246,7 @@ export async function deleteAudioProcessingTemplate(templateId: string) {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`, { method: 'DELETE' });
|
await apiDelete(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.delete') });
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
|
||||||
}
|
|
||||||
showToast(t('audio_processing.deleted'), 'success');
|
showToast(t('audio_processing.deleted'), 'success');
|
||||||
audioProcessingTemplatesCache.invalidate();
|
audioProcessingTemplatesCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { _cachedAudioSources, _cachedAudioTemplates, _cachedAudioProcessingTemplates, audioProcessingTemplatesCache, apiKey, audioSourcesCache } from '../core/state.ts';
|
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 { logError } from '../core/log.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.ts';
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
@@ -178,16 +179,10 @@ export async function saveAudioSource() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const method = id ? 'PUT' : 'POST';
|
if (id) {
|
||||||
const url = id ? `/audio-sources/${id}` : '/audio-sources';
|
await apiPut(`/audio-sources/${id}`, payload);
|
||||||
const resp = await fetchWithAuth(url, {
|
} else {
|
||||||
method,
|
await apiPost('/audio-sources', payload);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
|
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
|
||||||
audioSourceModal.forceClose();
|
audioSourceModal.forceClose();
|
||||||
@@ -203,9 +198,7 @@ export async function saveAudioSource() {
|
|||||||
|
|
||||||
export async function editAudioSource(sourceId: any) {
|
export async function editAudioSource(sourceId: any) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
|
const data = await apiGet<any>(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('audio_source.error.load'));
|
|
||||||
const data = await resp.json();
|
|
||||||
await showAudioSourceModal(data.source_type, data);
|
await showAudioSourceModal(data.source_type, data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
@@ -217,12 +210,9 @@ export async function editAudioSource(sourceId: any) {
|
|||||||
|
|
||||||
export async function cloneAudioSource(sourceId: any) {
|
export async function cloneAudioSource(sourceId: any) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
|
const data = await apiGet<any>(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('audio_source.error.load'));
|
const { id: _omit, ...rest } = data;
|
||||||
const data = await resp.json();
|
await showAudioSourceModal(data.source_type, { ...rest, name: `${data.name} (copy)` });
|
||||||
delete data.id;
|
|
||||||
data.name = data.name + ' (copy)';
|
|
||||||
await showAudioSourceModal(data.source_type, data);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@@ -236,11 +226,7 @@ export async function deleteAudioSource(sourceId: any) {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`, { method: 'DELETE' });
|
await apiDelete(`/audio-sources/${sourceId}`);
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
showToast(t('audio_source.deleted'), 'success');
|
showToast(t('audio_source.deleted'), 'success');
|
||||||
audioSourcesCache.invalidate();
|
audioSourcesCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
@@ -267,9 +253,7 @@ let _cachedDevicesByEngine = {};
|
|||||||
|
|
||||||
async function _loadAudioDevices() {
|
async function _loadAudioDevices() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/audio-devices');
|
const data = await apiGet<{ by_engine?: Record<string, any[]> }>('/audio-devices');
|
||||||
if (!resp.ok) throw new Error('fetch failed');
|
|
||||||
const data = await resp.json();
|
|
||||||
_cachedDevicesByEngine = data.by_engine || {};
|
_cachedDevicesByEngine = data.by_engine || {};
|
||||||
} catch {
|
} catch {
|
||||||
_cachedDevicesByEngine = {};
|
_cachedDevicesByEngine = {};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@
|
|||||||
import {
|
import {
|
||||||
calibrationTestState, EDGE_TEST_COLORS, displaysCache,
|
calibrationTestState, EDGE_TEST_COLORS, displaysCache,
|
||||||
} from '../core/state.ts';
|
} 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 { colorStripSourcesCache, devicesCache } from '../core/state.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast } from '../core/ui.ts';
|
import { showToast } from '../core/ui.ts';
|
||||||
@@ -92,10 +93,7 @@ async function _clearCSSTestMode() {
|
|||||||
const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value;
|
const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value;
|
||||||
if (!testDeviceId) return;
|
if (!testDeviceId) return;
|
||||||
try {
|
try {
|
||||||
await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
|
await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges: {} });
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ device_id: testDeviceId, edges: {} }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to clear CSS test mode:', err);
|
console.error('Failed to clear CSS test mode:', err);
|
||||||
}
|
}
|
||||||
@@ -109,11 +107,8 @@ function _setOverlayBtnActive(active: any) {
|
|||||||
|
|
||||||
async function _checkOverlayStatus(cssId: any) {
|
async function _checkOverlayStatus(cssId: any) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
|
const data = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`);
|
||||||
if (resp.ok) {
|
_setOverlayBtnActive(!!data.active);
|
||||||
const data = await resp.json();
|
|
||||||
_setOverlayBtnActive(data.active);
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +116,7 @@ export async function toggleCalibrationOverlay() {
|
|||||||
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
|
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
|
||||||
if (!cssId) return;
|
if (!cssId) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
|
const { active } = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`);
|
||||||
if (!resp.ok) return;
|
|
||||||
const { active } = await resp.json();
|
|
||||||
if (active) {
|
if (active) {
|
||||||
await stopCSSOverlay(cssId);
|
await stopCSSOverlay(cssId);
|
||||||
_setOverlayBtnActive(false);
|
_setOverlayBtnActive(false);
|
||||||
@@ -143,14 +136,11 @@ export async function toggleCalibrationOverlay() {
|
|||||||
|
|
||||||
export async function showCalibration(deviceId: any) {
|
export async function showCalibration(deviceId: any) {
|
||||||
try {
|
try {
|
||||||
const [response, displays] = await Promise.all([
|
const [device, displays] = await Promise.all([
|
||||||
fetchWithAuth(`/devices/${deviceId}`),
|
apiGet<any>(`/devices/${deviceId}`),
|
||||||
displaysCache.fetch().catch((): any[] => []),
|
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 calibration = device.calibration;
|
||||||
|
|
||||||
const preview = document.querySelector('.calibration-preview') as HTMLElement;
|
const preview = document.querySelector('.calibration-preview') as HTMLElement;
|
||||||
@@ -843,17 +833,9 @@ export async function toggleTestEdge(edge: any) {
|
|||||||
updateCalibrationPreview();
|
updateCalibrationPreview();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
|
await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges }, {
|
||||||
method: 'PUT',
|
errorMessage: t('calibration.error.test_toggle_failed'),
|
||||||
body: JSON.stringify({ device_id: testDeviceId, edges }),
|
|
||||||
});
|
});
|
||||||
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) {
|
} catch (err: any) {
|
||||||
if (err.isAuth) return;
|
if (err.isAuth) return;
|
||||||
console.error('Failed to toggle CSS test edge:', err);
|
console.error('Failed to toggle CSS test edge:', err);
|
||||||
@@ -875,17 +857,9 @@ export async function toggleTestEdge(edge: any) {
|
|||||||
updateCalibrationPreview();
|
updateCalibrationPreview();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`/devices/${deviceId}/calibration/test`, {
|
await apiPut(`/devices/${deviceId}/calibration/test`, { edges }, {
|
||||||
method: 'PUT',
|
errorMessage: t('calibration.error.test_toggle_failed'),
|
||||||
body: JSON.stringify({ edges })
|
|
||||||
});
|
});
|
||||||
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) {
|
} catch (err: any) {
|
||||||
if (err.isAuth) return;
|
if (err.isAuth) return;
|
||||||
console.error('Failed to toggle test edge:', err);
|
console.error('Failed to toggle test edge:', err);
|
||||||
@@ -965,34 +939,23 @@ export async function saveCalibration() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
|
||||||
if (cssMode) {
|
if (cssMode) {
|
||||||
const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture';
|
const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture';
|
||||||
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
|
await apiPut(`/color-strip-sources/${cssId}`, { source_type: cssSourceType, calibration, led_count: declaredLedCount }, {
|
||||||
method: 'PUT',
|
errorMessage: t('calibration.error.save_failed'),
|
||||||
body: JSON.stringify({ source_type: cssSourceType, calibration, led_count: declaredLedCount }),
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
|
await apiPut(`/devices/${deviceId}/calibration`, calibration, {
|
||||||
method: 'PUT',
|
errorMessage: t('calibration.error.save_failed'),
|
||||||
body: JSON.stringify(calibration),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (response.ok) {
|
showToast(t('calibration.saved'), 'success');
|
||||||
showToast(t('calibration.saved'), 'success');
|
if (cssMode) colorStripSourcesCache.invalidate();
|
||||||
if (cssMode) colorStripSourcesCache.invalidate();
|
calibModal.forceClose();
|
||||||
calibModal.forceClose();
|
if (cssMode) {
|
||||||
if (cssMode) {
|
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||||
if (window.loadTargetsTab) window.loadTargetsTab();
|
|
||||||
} else {
|
|
||||||
window.loadDevices();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
window.loadDevices();
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.isAuth) return;
|
if (err.isAuth) return;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
* Surface keys are free-form strings — anything calling `setCardMode` is
|
* Surface keys are free-form strings — anything calling `setCardMode` is
|
||||||
* implicitly registering that key. Defaults are returned for unknown keys.
|
* 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';
|
import { t } from '../core/i18n.ts';
|
||||||
|
|
||||||
const LS_KEY = 'card_modes_v1';
|
const LS_KEY = 'card_modes_v1';
|
||||||
@@ -131,10 +131,7 @@ function _scheduleServerPush(): void {
|
|||||||
|
|
||||||
async function _pushToServer(prefs: CardModePrefsV1): Promise<void> {
|
async function _pushToServer(prefs: CardModePrefsV1): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fetchWithAuth('/preferences/card-modes', {
|
await apiPut('/preferences/card-modes', prefs);
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(prefs),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('card-modes server PUT failed', e);
|
console.warn('card-modes server PUT failed', e);
|
||||||
}
|
}
|
||||||
@@ -160,9 +157,7 @@ export function hydrateCardModesFromCache(): CardModePrefsV1 {
|
|||||||
export async function syncCardModesFromServer(): Promise<void> {
|
export async function syncCardModesFromServer(): Promise<void> {
|
||||||
if (_serverSyncedOnce) return;
|
if (_serverSyncedOnce) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/preferences/card-modes');
|
const data = await apiGet<any>('/preferences/card-modes');
|
||||||
if (!resp || !resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data && typeof data === 'object' && (data as Record<string, unknown>).version) {
|
if (data && typeof data === 'object' && (data as Record<string, unknown>).version) {
|
||||||
_current = _normalise(data);
|
_current = _normalise(data);
|
||||||
_persistLocal();
|
_persistLocal();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Extracted from color-strips.ts to reduce file size.
|
* 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 { gradientsCache, GradientEntity } from '../../core/state.ts';
|
||||||
import { t } from '../../core/i18n.ts';
|
import { t } from '../../core/i18n.ts';
|
||||||
import { showToast, showConfirm } from '../../core/ui.ts';
|
import { showToast, showConfirm } from '../../core/ui.ts';
|
||||||
@@ -98,11 +98,7 @@ export async function promptAndSaveGradientPreset() {
|
|||||||
color: s.color,
|
color: s.color,
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await fetchWithAuth('/gradients', {
|
await apiPost('/gradients', { name: name.trim(), stops });
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: name.trim(), stops }),
|
|
||||||
});
|
|
||||||
await gradientsCache.fetch({ force: true });
|
await gradientsCache.fetch({ force: true });
|
||||||
showToast(t('color_strip.gradient.preset.saved'), 'success');
|
showToast(t('color_strip.gradient.preset.saved'), 'success');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -112,7 +108,7 @@ export async function promptAndSaveGradientPreset() {
|
|||||||
|
|
||||||
export async function deleteAndRefreshGradientPreset(gradientId: any) {
|
export async function deleteAndRefreshGradientPreset(gradientId: any) {
|
||||||
try {
|
try {
|
||||||
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
|
await apiDelete(`/gradients/${gradientId}`);
|
||||||
await gradientsCache.fetch({ force: true });
|
await gradientsCache.fetch({ force: true });
|
||||||
showToast(t('color_strip.gradient.preset.deleted'), 'success');
|
showToast(t('color_strip.gradient.preset.deleted'), 'success');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -221,12 +217,10 @@ export async function saveGradientEntity() {
|
|||||||
const payload: any = { name, stops, description, tags };
|
const payload: any = { name, stops, description, tags };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = id ? `/gradients/${id}` : '/gradients';
|
if (id) {
|
||||||
const method = id ? 'PUT' : 'POST';
|
await apiPut(`/gradients/${id}`, payload, { errorMessage: t('gradient.error.save_failed') });
|
||||||
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
} else {
|
||||||
if (!res!.ok) {
|
await apiPost('/gradients', payload, { errorMessage: t('gradient.error.save_failed') });
|
||||||
const err = await res!.json();
|
|
||||||
throw new Error(err.detail || 'Failed to save gradient');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(id ? t('gradient.updated') : t('gradient.created'), 'success');
|
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 }));
|
const ok = await showConfirm(t('gradient.confirm_delete', { name: g.name }));
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
|
await apiDelete(`/gradients/${gradientId}`, { errorMessage: t('gradient.error.delete_failed') });
|
||||||
gradientsCache.invalidate();
|
gradientsCache.invalidate();
|
||||||
showToast(t('gradient.deleted'), 'success');
|
showToast(t('gradient.deleted'), 'success');
|
||||||
if (window.loadPictureSources) await window.loadPictureSources();
|
if (window.loadPictureSources) await window.loadPictureSources();
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* Extracted from color-strips.ts to reduce file size.
|
* 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 { t } from '../../core/i18n.ts';
|
||||||
import { showToast } from '../../core/ui.ts';
|
import { showToast } from '../../core/ui.ts';
|
||||||
import {
|
import {
|
||||||
@@ -313,20 +314,17 @@ export function ensureNotifSoundEntitySelect() {
|
|||||||
|
|
||||||
export async function testNotification(sourceId: string) {
|
export async function testNotification(sourceId: string) {
|
||||||
try {
|
try {
|
||||||
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
|
const data = await apiPost<{ streams_notified?: number }>(
|
||||||
if (!resp.ok) {
|
`/color-strip-sources/${sourceId}/notify`, undefined,
|
||||||
const err = await resp.json().catch(() => ({}));
|
{ errorMessage: t('color_strip.notification.test.error') },
|
||||||
showToast(err.detail || t('color_strip.notification.test.error'), 'error');
|
);
|
||||||
return;
|
if ((data.streams_notified ?? 0) > 0) {
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.streams_notified > 0) {
|
|
||||||
showToast(t('color_strip.notification.test.ok'), 'success');
|
showToast(t('color_strip.notification.test.ok'), 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast(t('color_strip.notification.test.no_streams'), 'warning');
|
showToast(t('color_strip.notification.test.no_streams'), 'warning');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
showToast(t('color_strip.notification.test.error'), 'error');
|
showToast(e?.message || t('color_strip.notification.test.error'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,9 +353,7 @@ async function _loadNotificationHistory() {
|
|||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = (await fetchWithAuth('/color-strip-sources/os-notifications/history'))!;
|
const data = await apiGet<any>('/color-strip-sources/os-notifications/history');
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
if (!data.available) {
|
if (!data.available) {
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* not a closed enum. New cards can be added in v1.1+ (audio meters, alerts,
|
* not a closed enum. New cards can be added in v1.1+ (audio meters, alerts,
|
||||||
* preview strips, etc.) without a schema bump or migration.
|
* 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 LS_KEY = 'dashboard_layout_v1';
|
||||||
const SCHEMA_VERSION = 1;
|
const SCHEMA_VERSION = 1;
|
||||||
@@ -397,9 +397,7 @@ export function hydrateDashboardLayoutFromCache(): DashboardLayoutV1 {
|
|||||||
export async function syncDashboardLayoutFromServer(): Promise<void> {
|
export async function syncDashboardLayoutFromServer(): Promise<void> {
|
||||||
if (_serverSyncedOnce) return;
|
if (_serverSyncedOnce) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/preferences/dashboard-layout');
|
const data = await apiGet<any>('/preferences/dashboard-layout');
|
||||||
if (!resp || !resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data && typeof data === 'object' && data.version) {
|
if (data && typeof data === 'object' && data.version) {
|
||||||
const merged = _mergeWithDefaults(data);
|
const merged = _mergeWithDefaults(data);
|
||||||
_current = merged;
|
_current = merged;
|
||||||
@@ -431,10 +429,7 @@ export function saveDashboardLayout(next: DashboardLayoutV1): void {
|
|||||||
|
|
||||||
async function _pushToServer(layout: DashboardLayoutV1): Promise<void> {
|
async function _pushToServer(layout: DashboardLayoutV1): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fetchWithAuth('/preferences/dashboard-layout', {
|
await apiPut('/preferences/dashboard-layout', layout);
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(layout),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('dashboard layout PUT failed', e);
|
console.warn('dashboard layout PUT failed', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
_discoveryCache, set_discoveryCache,
|
_discoveryCache, set_discoveryCache,
|
||||||
csptCache,
|
csptCache,
|
||||||
} from '../core/state.ts';
|
} 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 { devicesCache } from '../core/state.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, desktopFocus } from '../core/ui.ts';
|
import { showToast, desktopFocus } from '../core/ui.ts';
|
||||||
@@ -1036,20 +1037,11 @@ export async function scanForDevices(forceType?: any) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const scanTimeout = scanType === 'ble' ? 8 : 3;
|
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';
|
loading.style.display = 'none';
|
||||||
if (scanBtn) scanBtn.disabled = false;
|
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 || [];
|
_discoveryCache[scanType] = data.devices || [];
|
||||||
|
|
||||||
// Only render if the user is still on this type
|
// 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', {
|
await apiPost('/devices', body, { errorMessage: t('device_discovery.error.add_failed') });
|
||||||
method: 'POST',
|
showToast(t('device_discovery.added'), 'success');
|
||||||
body: JSON.stringify(body)
|
devicesCache.invalidate();
|
||||||
});
|
addDeviceModal.forceClose();
|
||||||
|
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
||||||
if (response.ok) {
|
if (!localStorage.getItem('deviceTutorialSeen')) {
|
||||||
const result = await response.json();
|
localStorage.setItem('deviceTutorialSeen', '1');
|
||||||
// result is logged by the API layer; no console.log here.
|
setTimeout(() => {
|
||||||
showToast(t('device_discovery.added'), 'success');
|
if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial();
|
||||||
devicesCache.invalidate();
|
}, 300);
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.isAuth) return;
|
if (err.isAuth) return;
|
||||||
console.error('Failed to add device:', err);
|
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>`;
|
container.innerHTML = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
const data = await apiGet<{ zones?: any[] }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`, {
|
||||||
if (!resp.ok) {
|
errorMessage: t('device.openrgb.zone.error'),
|
||||||
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();
|
|
||||||
_renderZoneCheckboxes(container, data.zones, preChecked);
|
_renderZoneCheckboxes(container, data.zones, preChecked);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.isAuth) return;
|
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) {
|
export async function cloneDevice(deviceId: any) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/devices/${deviceId}`);
|
const device = await apiGet<any>(`/devices/${deviceId}`, { errorMessage: t('device.error.load_failed') });
|
||||||
if (!resp.ok) throw new Error('Failed to load device');
|
|
||||||
const device = await resp.json();
|
|
||||||
showAddDevice(device.device_type || 'wled', device);
|
showAddDevice(device.device_type || 'wled', device);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
_deviceBrightnessCache, updateDeviceBrightness,
|
_deviceBrightnessCache, updateDeviceBrightness,
|
||||||
csptCache,
|
csptCache,
|
||||||
} from '../core/state.ts';
|
} 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 { 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 { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
@@ -363,19 +364,13 @@ export async function turnOffDevice(deviceId: any) {
|
|||||||
const confirmed = await showConfirm(t('confirm.turn_off_device'));
|
const confirmed = await showConfirm(t('confirm.turn_off_device'));
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, {
|
await apiPut(`/devices/${deviceId}/power`, { power: false }, {
|
||||||
method: 'PUT',
|
errorMessage: t('device.error.power_off_failed'),
|
||||||
body: JSON.stringify({ power: false })
|
|
||||||
});
|
});
|
||||||
if (setResp.ok) {
|
showToast(t('device.power.off_success'), 'success');
|
||||||
showToast(t('device.power.off_success'), 'success');
|
|
||||||
} else {
|
|
||||||
const error = await setResp.json();
|
|
||||||
showToast(error.detail || 'Failed', 'error');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.isAuth) return;
|
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;
|
const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null;
|
||||||
if (btn) btn.classList.add('spinning');
|
if (btn) btn.classList.add('spinning');
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
|
const data = await apiPost<{ device_online?: boolean; device_latency_ms?: number }>(
|
||||||
if (resp.ok) {
|
`/devices/${deviceId}/ping`, undefined, { errorMessage: t('device.ping.error') },
|
||||||
const data = await resp.json();
|
);
|
||||||
const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?';
|
const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?';
|
||||||
showToast(data.device_online
|
showToast(data.device_online
|
||||||
? t('device.ping.online', { ms })
|
? t('device.ping.online', { ms })
|
||||||
: t('device.ping.offline'), data.device_online ? 'success' : 'error');
|
: t('device.ping.offline'), data.device_online ? 'success' : 'error');
|
||||||
// Refresh device cards to update health dot
|
// Refresh device cards to update health dot
|
||||||
devicesCache.invalidate();
|
devicesCache.invalidate();
|
||||||
await window.loadDevices();
|
await window.loadDevices();
|
||||||
} else {
|
|
||||||
const err = await resp.json();
|
|
||||||
showToast(err.detail || 'Ping failed', 'error');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
showToast(t('device.ping.error'), 'error');
|
showToast(error.message || t('device.ping.error'), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
if (btn) btn.classList.remove('spinning');
|
if (btn) btn.classList.remove('spinning');
|
||||||
}
|
}
|
||||||
@@ -414,30 +405,20 @@ export async function removeDevice(deviceId: any) {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`/devices/${deviceId}`, {
|
await apiDelete(`/devices/${deviceId}`, { errorMessage: t('device.error.remove_failed') });
|
||||||
method: 'DELETE',
|
showToast(t('device.removed'), 'success');
|
||||||
});
|
devicesCache.invalidate();
|
||||||
if (response.ok) {
|
window.loadDevices();
|
||||||
showToast(t('device.removed'), 'success');
|
|
||||||
devicesCache.invalidate();
|
|
||||||
window.loadDevices();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
showToast(error.detail || t('device.error.remove_failed'), 'error');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
console.error('Failed to remove device:', error);
|
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) {
|
export async function showSettings(deviceId: any) {
|
||||||
try {
|
try {
|
||||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`);
|
const device = await apiGet<any>(`/devices/${deviceId}`, { errorMessage: t('device.error.settings_load_failed') });
|
||||||
if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; }
|
|
||||||
|
|
||||||
const device = await deviceResponse.json();
|
|
||||||
const isAdalight = isSerialDevice(device.device_type);
|
const isAdalight = isSerialDevice(device.device_type);
|
||||||
const caps = device.capabilities || [];
|
const caps = device.capabilities || [];
|
||||||
|
|
||||||
@@ -934,18 +915,7 @@ export async function saveDeviceSettings() {
|
|||||||
}
|
}
|
||||||
const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '';
|
const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '';
|
||||||
body.default_css_processing_template_id = csptId;
|
body.default_css_processing_template_id = csptId;
|
||||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
|
await apiPut(`/devices/${deviceId}`, body, { errorMessage: t('device.error.update') });
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(t('settings.saved'), 'success');
|
showToast(t('settings.saved'), 'success');
|
||||||
devicesCache.invalidate();
|
devicesCache.invalidate();
|
||||||
@@ -978,16 +948,9 @@ export async function saveCardBrightness(deviceId: any, value: any) {
|
|||||||
const bri = parseInt(value);
|
const bri = parseInt(value);
|
||||||
updateDeviceBrightness(deviceId, bri);
|
updateDeviceBrightness(deviceId, bri);
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`, {
|
await apiPut(`/devices/${deviceId}/brightness`, { brightness: bri }, {
|
||||||
method: 'PUT',
|
errorMessage: t('device.error.brightness'),
|
||||||
body: JSON.stringify({ brightness: bri })
|
|
||||||
});
|
});
|
||||||
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) {
|
} catch (err: any) {
|
||||||
if (err.isAuth) return;
|
if (err.isAuth) return;
|
||||||
showToast(err.message || t('device.error.brightness'), 'error');
|
showToast(err.message || t('device.error.brightness'), 'error');
|
||||||
@@ -999,9 +962,7 @@ export async function fetchDeviceBrightness(deviceId: any) {
|
|||||||
if (_brightnessFetchInFlight.has(deviceId)) return;
|
if (_brightnessFetchInFlight.has(deviceId)) return;
|
||||||
_brightnessFetchInFlight.add(deviceId);
|
_brightnessFetchInFlight.add(deviceId);
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`);
|
const data = await apiGet<any>(`/devices/${deviceId}/brightness`);
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
updateDeviceBrightness(deviceId, data.brightness);
|
updateDeviceBrightness(deviceId, data.brightness);
|
||||||
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
|
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
|
||||||
if (slider) {
|
if (slider) {
|
||||||
@@ -1078,9 +1039,7 @@ async function _populateSettingsSerialPorts(currentUrl: any) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const discoverType = settingsModal.deviceType || 'adalight';
|
const discoverType = settingsModal.deviceType || 'adalight';
|
||||||
const resp = await fetchWithAuth(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
|
const data = await apiGet<{ devices?: any[] }>(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
const devices = data.devices || [];
|
const devices = data.devices || [];
|
||||||
|
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
@@ -1154,11 +1113,9 @@ export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) {
|
|||||||
_zoneCountInFlight.add(baseUrl);
|
_zoneCountInFlight.add(baseUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
const data = await apiGet<{ zones?: Array<{ name: string; led_count: number }> }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
const counts: any = {};
|
const counts: any = {};
|
||||||
for (const z of data.zones) {
|
for (const z of (data.zones || [])) {
|
||||||
counts[z.name.toLowerCase()] = z.led_count;
|
counts[z.name.toLowerCase()] = z.led_count;
|
||||||
}
|
}
|
||||||
_zoneCountCache[baseUrl] = counts;
|
_zoneCountCache[baseUrl] = counts;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
availableEngines,
|
availableEngines,
|
||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
import { t } from '../core/i18n.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 { showToast } from '../core/ui.ts';
|
||||||
import type { Display } from '../types.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>';
|
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/config/displays?engine_type=${engineType}`);
|
const data = await apiGet<{ displays?: Display[] }>(`/config/displays?engine_type=${engineType}`);
|
||||||
if (!resp.ok) throw new Error(`${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
const displays = data.displays || [];
|
const displays = data.displays || [];
|
||||||
|
|
||||||
// Store in cache so selectDisplay() can look them up
|
// Store in cache so selectDisplay() can look them up
|
||||||
@@ -137,14 +135,10 @@ window._adbConnectFromPicker = async function () {
|
|||||||
|
|
||||||
input.disabled = true;
|
input.disabled = true;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/adb/connect', {
|
// No errorMessage option: the catch already prefixes the toast with
|
||||||
method: 'POST',
|
// the localised `displays.picker.adb_connect.error` label, and the
|
||||||
body: JSON.stringify({ address }),
|
// server's `detail` (or `HTTP <status>` fallback) becomes the suffix.
|
||||||
});
|
await apiPost('/adb/connect', { address });
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || 'Connection failed');
|
|
||||||
}
|
|
||||||
showToast(t('displays.picker.adb_connect.success'), 'success');
|
showToast(t('displays.picker.adb_connect.success'), 'success');
|
||||||
|
|
||||||
// Refresh the picker with updated device list
|
// Refresh the picker with updated device list
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
gameIntegrationsCache, gameAdaptersCache,
|
gameIntegrationsCache, gameAdaptersCache,
|
||||||
_cachedGameIntegrations, _cachedGameAdapters,
|
_cachedGameIntegrations, _cachedGameAdapters,
|
||||||
} from '../core/state.ts';
|
} 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 { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.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 ──
|
// ── Bulk actions ──
|
||||||
|
|
||||||
function _bulkDeleteGameIntegrations(ids: string[]) {
|
function _bulkDeleteGameIntegrations(ids: string[]) {
|
||||||
return Promise.allSettled(ids.map(id =>
|
return Promise.allSettled(ids.map(id => apiDelete(`/game-integrations/${id}`))).then(results => {
|
||||||
fetchWithAuth(`/game-integrations/${id}`, { method: 'DELETE' })
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
)).then(results => {
|
|
||||||
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
|
|
||||||
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||||
else showToast(t('game_integration.deleted'), 'success');
|
else showToast(t('game_integration.deleted'), 'success');
|
||||||
gameIntegrationsCache.invalidate();
|
gameIntegrationsCache.invalidate();
|
||||||
@@ -192,13 +191,9 @@ export async function autoSetupGameIntegration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuth(`/game-integrations/${id}/auto-setup`, { method: 'POST' });
|
const data = await apiPost<{ success: boolean; file_path?: string; token_generated?: boolean; message?: string }>(
|
||||||
if (!res || !res.ok) {
|
`/game-integrations/${id}/auto-setup`, undefined, { errorMessage: t('game_integration.auto_setup.failed') },
|
||||||
const err = await res!.json();
|
);
|
||||||
showToast(err.detail || t('game_integration.auto_setup.failed'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
let msg = t('game_integration.auto_setup.success');
|
let msg = t('game_integration.auto_setup.success');
|
||||||
if (data.file_path) msg += `\n${data.file_path}`;
|
if (data.file_path) msg += `\n${data.file_path}`;
|
||||||
@@ -424,11 +419,8 @@ let _cachedPresets: EffectPreset[] = [];
|
|||||||
async function _loadPresets(): Promise<EffectPreset[]> {
|
async function _loadPresets(): Promise<EffectPreset[]> {
|
||||||
if (_cachedPresets.length > 0) return _cachedPresets;
|
if (_cachedPresets.length > 0) return _cachedPresets;
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuth('/game-integrations/presets');
|
const data = await apiGet<{ presets?: EffectPreset[] }>('/game-integrations/presets');
|
||||||
if (res && res.ok) {
|
_cachedPresets = data.presets || [];
|
||||||
const data = await res.json();
|
|
||||||
_cachedPresets = data.presets || [];
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return _cachedPresets;
|
return _cachedPresets;
|
||||||
}
|
}
|
||||||
@@ -494,10 +486,8 @@ function _startEventMonitor(integrationId: string) {
|
|||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuth(`/game-integrations/${integrationId}/events`);
|
const data = await apiGet<{ events?: GameEventRecord[] }>(`/game-integrations/${integrationId}/events`);
|
||||||
if (!res || !res.ok) return;
|
const events = data.events || [];
|
||||||
const data = await res.json();
|
|
||||||
const events: GameEventRecord[] = data.events || [];
|
|
||||||
if (events.length === 0) return;
|
if (events.length === 0) return;
|
||||||
feed.innerHTML = events.slice(0, 20).map(ev => {
|
feed.innerHTML = events.slice(0, 20).map(ev => {
|
||||||
const ts = new Date(ev.timestamp).toLocaleTimeString();
|
const ts = new Date(ev.timestamp).toLocaleTimeString();
|
||||||
@@ -535,9 +525,7 @@ export function testGameConnection() {
|
|||||||
_connectionTestTimer = setInterval(async () => {
|
_connectionTestTimer = setInterval(async () => {
|
||||||
attempts++;
|
attempts++;
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuth(`/game-integrations/${id}/status`);
|
const status = await apiGet<GameIntegrationStatus>(`/game-integrations/${id}/status`);
|
||||||
if (!res || !res.ok) return;
|
|
||||||
const status: GameIntegrationStatus = await res.json();
|
|
||||||
if (status.event_count > 0) {
|
if (status.event_count > 0) {
|
||||||
clearInterval(_connectionTestTimer!);
|
clearInterval(_connectionTestTimer!);
|
||||||
_connectionTestTimer = null;
|
_connectionTestTimer = null;
|
||||||
@@ -725,12 +713,10 @@ export async function saveGameIntegration() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = id ? `/game-integrations/${id}` : '/game-integrations';
|
if (id) {
|
||||||
const method = id ? 'PUT' : 'POST';
|
await apiPut(`/game-integrations/${id}`, payload, { errorMessage: t('game_integration.error.save_failed') });
|
||||||
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
} else {
|
||||||
if (!res || !res.ok) {
|
await apiPost('/game-integrations', payload, { errorMessage: t('game_integration.error.save_failed') });
|
||||||
const err = await res!.json();
|
|
||||||
throw new Error(err.detail || t('game_integration.error.save_failed'));
|
|
||||||
}
|
}
|
||||||
showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success');
|
showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success');
|
||||||
gameIntegrationsCache.invalidate();
|
gameIntegrationsCache.invalidate();
|
||||||
@@ -746,7 +732,7 @@ export async function deleteGameIntegration(entityId: string) {
|
|||||||
const ok = await showConfirm(t('game_integration.confirm_delete'));
|
const ok = await showConfirm(t('game_integration.confirm_delete'));
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
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');
|
showToast(t('game_integration.deleted'), 'success');
|
||||||
gameIntegrationsCache.invalidate();
|
gameIntegrationsCache.invalidate();
|
||||||
loadGameIntegrations();
|
loadGameIntegrations();
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
colorStripSourcesCache, outputTargetsCache, valueSourcesCache,
|
colorStripSourcesCache, outputTargetsCache, valueSourcesCache,
|
||||||
getHAEntityFriendlyName, setHAEntityNames,
|
getHAEntityFriendlyName, setHAEntityNames,
|
||||||
} from '../core/state.ts';
|
} 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 { logError } from '../core/log.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
@@ -149,9 +150,7 @@ function _getEntityItems() {
|
|||||||
async function _fetchHAEntities(haSourceId: string): Promise<void> {
|
async function _fetchHAEntities(haSourceId: string): Promise<void> {
|
||||||
if (!haSourceId) { _cachedHAEntities = []; return; }
|
if (!haSourceId) { _cachedHAEntities = []; return; }
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
|
||||||
if (!resp.ok) { _cachedHAEntities = []; return; }
|
|
||||||
const data = await resp.json();
|
|
||||||
_cachedHAEntities = data.entities || [];
|
_cachedHAEntities = data.entities || [];
|
||||||
// Mirror into the shared cache so card chips/swatches across the
|
// Mirror into the shared cache so card chips/swatches across the
|
||||||
// app pick up friendly names on the next render.
|
// app pick up friendly names on the next render.
|
||||||
@@ -381,9 +380,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
|||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
editData = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||||
if (!resp.ok) throw new Error('Failed to load target');
|
|
||||||
editData = await resp.json();
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@@ -542,22 +539,10 @@ export async function saveHALightEditor(): Promise<void> {
|
|||||||
payload.target_type = 'ha_light';
|
payload.target_type = 'ha_light';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
response = await fetchWithAuth(`/output-targets/${targetId}`, {
|
await apiPut(`/output-targets/${targetId}`, payload);
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
response = await fetchWithAuth('/output-targets', {
|
await apiPost('/output-targets', payload);
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${response.status}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(targetId ? t('ha_light.updated') : t('ha_light.created'), 'success');
|
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> {
|
export async function cloneHALightTarget(targetId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
const data = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||||
if (!resp.ok) throw new Error('Failed to load target');
|
const { id: _omit, ...rest } = data;
|
||||||
const data = await resp.json();
|
await showHALightEditor(null, { ...rest, name: `${data.name} (copy)` });
|
||||||
delete data.id;
|
|
||||||
data.name = data.name + ' (copy)';
|
|
||||||
await showHALightEditor(null, data);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
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> {
|
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' });
|
await apiPost(`/output-targets/${targetId}/${action}`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
outputTargetsCache.invalidate();
|
outputTargetsCache.invalidate();
|
||||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||||
} catch (e: any) {
|
} 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?');
|
const confirmed = await showConfirm(t('confirm.turn_off_ha_light') || 'Turn off mapped lights?');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(
|
await apiPost(`/output-targets/${targetId}/ha-light/turn-off`, undefined, {
|
||||||
`/output-targets/${targetId}/ha-light/turn-off`,
|
errorMessage: t('ha_light.turn_off.failed') || 'Failed to turn off lights',
|
||||||
{ method: 'POST' },
|
});
|
||||||
);
|
showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success');
|
||||||
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');
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
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,
|
_cachedHASources, haSourcesCache,
|
||||||
_haEntityNamesCache, setHAEntityNames,
|
_haEntityNamesCache, setHAEntityNames,
|
||||||
} from '../core/state.ts';
|
} 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 { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.ts';
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
@@ -75,12 +76,10 @@ const haSourceModal = new HASourceModal();
|
|||||||
export async function fetchHAEntities(haSourceId: string): Promise<void> {
|
export async function fetchHAEntities(haSourceId: string): Promise<void> {
|
||||||
if (!haSourceId) return;
|
if (!haSourceId) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
setHAEntityNames(haSourceId, data.entities || []);
|
setHAEntityNames(haSourceId, data.entities || []);
|
||||||
} catch {
|
} 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;
|
if (token) payload.token = token;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const method = id ? 'PUT' : 'POST';
|
if (id) {
|
||||||
const url = id ? `/home-assistant/sources/${id}` : '/home-assistant/sources';
|
await apiPut(`/home-assistant/sources/${id}`, payload);
|
||||||
const resp = await fetchWithAuth(url, {
|
} else {
|
||||||
method,
|
await apiPost('/home-assistant/sources', payload);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success');
|
showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success');
|
||||||
haSourceModal.forceClose();
|
haSourceModal.forceClose();
|
||||||
@@ -199,9 +192,7 @@ export async function saveHASource(): Promise<void> {
|
|||||||
|
|
||||||
export async function editHASource(sourceId: string): Promise<void> {
|
export async function editHASource(sourceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`);
|
const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('ha_source.error.load'));
|
|
||||||
const data = await resp.json();
|
|
||||||
await showHASourceModal(data);
|
await showHASourceModal(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
@@ -211,12 +202,9 @@ export async function editHASource(sourceId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function cloneHASource(sourceId: string): Promise<void> {
|
export async function cloneHASource(sourceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`);
|
const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('ha_source.error.load'));
|
const { id: _omit, ...rest } = data;
|
||||||
const data = await resp.json();
|
await showHASourceModal({ ...rest, name: `${data.name} (copy)` } as HomeAssistantSource);
|
||||||
delete data.id;
|
|
||||||
data.name = data.name + ' (copy)';
|
|
||||||
await showHASourceModal(data);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
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'));
|
const confirmed = await showConfirm(t('ha_source.delete.confirm'));
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`, { method: 'DELETE' });
|
await apiDelete(`/home-assistant/sources/${sourceId}`);
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
showToast(t('ha_source.deleted'), 'success');
|
showToast(t('ha_source.deleted'), 'success');
|
||||||
haSourcesCache.invalidate();
|
haSourcesCache.invalidate();
|
||||||
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
||||||
@@ -251,9 +235,7 @@ export async function testHASource(): Promise<void> {
|
|||||||
if (testBtn) testBtn.classList.add('loading');
|
if (testBtn) testBtn.classList.add('loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/home-assistant/sources/${id}/test`, { method: 'POST' });
|
const data = await apiPost<HATestResult>(`/home-assistant/sources/${id}/test`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(`${t('ha_source.test.success')} | HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
|
showToast(`${t('ha_source.test.success')} | HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
|
||||||
} else {
|
} 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 ──
|
// ── Card rendering ──
|
||||||
|
|
||||||
export function createHASourceCard(source: HomeAssistantSource) {
|
export function createHASourceCard(source: HomeAssistantSource) {
|
||||||
@@ -328,9 +318,7 @@ const _haSourceActions: Record<string, (id: string) => void> = {
|
|||||||
|
|
||||||
async function _testHASourceFromCard(sourceId: string): Promise<void> {
|
async function _testHASourceFromCard(sourceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}/test`, { method: 'POST' });
|
const data = await apiPost<HATestResult>(`/home-assistant/sources/${sourceId}/test`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(`HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
|
showToast(`HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
import {
|
import {
|
||||||
_cachedHTTPEndpoints, httpEndpointsCache,
|
_cachedHTTPEndpoints, httpEndpointsCache,
|
||||||
} from '../core/state.ts';
|
} 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 { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.ts';
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
@@ -315,16 +316,10 @@ export async function saveHTTPEndpoint(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const method = id ? 'PUT' : 'POST';
|
if (id) {
|
||||||
const url = id ? `/http/endpoints/${id}` : '/http/endpoints';
|
await apiPut(`/http/endpoints/${id}`, payload);
|
||||||
const resp = await fetchWithAuth(url, {
|
} else {
|
||||||
method,
|
await apiPost('/http/endpoints', payload);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success');
|
showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success');
|
||||||
httpEndpointModal.forceClose();
|
httpEndpointModal.forceClose();
|
||||||
@@ -340,9 +335,7 @@ export async function saveHTTPEndpoint(): Promise<void> {
|
|||||||
|
|
||||||
export async function editHTTPEndpoint(endpointId: string): Promise<void> {
|
export async function editHTTPEndpoint(endpointId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
|
const data = await apiGet<HTTPEndpoint>(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
|
|
||||||
const data: HTTPEndpoint = await resp.json();
|
|
||||||
await showHTTPEndpointModal(data);
|
await showHTTPEndpointModal(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
@@ -352,14 +345,14 @@ export async function editHTTPEndpoint(endpointId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function cloneHTTPEndpoint(endpointId: string): Promise<void> {
|
export async function cloneHTTPEndpoint(endpointId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
|
const data = await apiGet<HTTPEndpoint>(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
|
const { id: _omit, ...rest } = data;
|
||||||
const data = await resp.json();
|
await showHTTPEndpointModal({
|
||||||
delete data.id;
|
...rest,
|
||||||
data.name = data.name + ' (copy)';
|
name: `${data.name} (copy)`,
|
||||||
// Cloning never reveals the token — user must re-enter if needed.
|
// Cloning never reveals the token — user must re-enter if needed.
|
||||||
data.auth_token_set = false;
|
auth_token_set: false,
|
||||||
await showHTTPEndpointModal(data);
|
} as HTTPEndpoint);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
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'));
|
const confirmed = await showConfirm(t('http_endpoint.delete.confirm'));
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' });
|
await apiDelete(`/http/endpoints/${endpointId}`);
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
showToast(t('http_endpoint.deleted'), 'success');
|
showToast(t('http_endpoint.deleted'), 'success');
|
||||||
httpEndpointsCache.invalidate();
|
httpEndpointsCache.invalidate();
|
||||||
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
||||||
@@ -427,13 +416,7 @@ export async function testHTTPEndpoint(): Promise<void> {
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/http/endpoints/test', {
|
const data = await apiPost<HTTPTestResponse>('/http/endpoints/test', { url, method, auth_token: token, headers, timeout_s });
|
||||||
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();
|
|
||||||
_renderTestResult(out, data);
|
_renderTestResult(out, data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
@@ -493,12 +476,7 @@ function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) {
|
|||||||
|
|
||||||
async function _testHTTPEndpointFromCard(endpointId: string): Promise<void> {
|
async function _testHTTPEndpointFromCard(endpointId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' });
|
const data = await apiPost<HTTPTestResponse>(`/http/endpoints/${endpointId}/test`);
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
const data: HTTPTestResponse = await resp.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const status = data.status_code != null ? ` (${data.status_code})` : '';
|
const status = data.status_code != null ? ` (${data.status_code})` : '';
|
||||||
showToast(`${t('http_endpoint.test.success')}${status}`, 'success');
|
showToast(`${t('http_endpoint.test.success')}${status}`, 'success');
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
|
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { t } from '../core/i18n.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 { showToast } from '../core/ui.ts';
|
||||||
import { devicesCache, outputTargetsCache } from '../core/state.ts';
|
import { devicesCache, outputTargetsCache } from '../core/state.ts';
|
||||||
import {
|
import {
|
||||||
@@ -494,22 +495,14 @@ async function _applyChange(nextIconId: string, nextColor: string): Promise<void
|
|||||||
if (adapter.bodyExtras) {
|
if (adapter.bodyExtras) {
|
||||||
Object.assign(body, adapter.bodyExtras(entityId));
|
Object.assign(body, adapter.bodyExtras(entityId));
|
||||||
}
|
}
|
||||||
const resp = await fetchWithAuth(adapter.endpoint(entityId), {
|
await apiPut(adapter.endpoint(entityId), body, { errorMessage: t('device.icon.error.save_failed') });
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (nextIconId) _pushRecent(nextIconId);
|
if (nextIconId) _pushRecent(nextIconId);
|
||||||
showToast(t('device.icon.saved') || 'Icon saved', 'success');
|
showToast(t('device.icon.saved') || 'Icon saved', 'success');
|
||||||
await adapter.reload();
|
await adapter.reload();
|
||||||
closeIconPicker();
|
closeIconPicker();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.isAuth) return;
|
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 { TreeNav } from '../core/tree-nav.ts';
|
||||||
import { updateSubTabHash } from './tabs.ts';
|
import { updateSubTabHash } from './tabs.ts';
|
||||||
import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.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 { showToast, setTabRefreshing } from '../core/ui.ts';
|
||||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||||
import { createHASourceCard, initHASourceDelegation } from './home-assistant-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) {
|
function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) {
|
||||||
return async (ids: string[]) => {
|
return async (ids: string[]) => {
|
||||||
const results = await Promise.allSettled(ids.map(id =>
|
const results = await Promise.allSettled(ids.map(id => apiDelete(`/${endpoint}/${id}`)));
|
||||||
fetchWithAuth(`/${endpoint}/${id}`, { method: 'DELETE' })
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
));
|
|
||||||
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
|
|
||||||
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||||
else showToast(t(toast), 'success');
|
else showToast(t(toast), 'success');
|
||||||
cache.invalidate();
|
cache.invalidate();
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { mqttSourcesCache } from '../core/state.ts';
|
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 { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.ts';
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
@@ -144,16 +145,10 @@ export async function saveMQTTSource(): Promise<void> {
|
|||||||
if (password) payload.password = password;
|
if (password) payload.password = password;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const method = id ? 'PUT' : 'POST';
|
if (id) {
|
||||||
const url = id ? `/mqtt/sources/${id}` : '/mqtt/sources';
|
await apiPut(`/mqtt/sources/${id}`, payload);
|
||||||
const resp = await fetchWithAuth(url, {
|
} else {
|
||||||
method,
|
await apiPost('/mqtt/sources', payload);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
showToast(t(id ? 'mqtt_source.updated' : 'mqtt_source.created'), 'success');
|
showToast(t(id ? 'mqtt_source.updated' : 'mqtt_source.created'), 'success');
|
||||||
mqttSourceModal.forceClose();
|
mqttSourceModal.forceClose();
|
||||||
@@ -168,9 +163,7 @@ export async function saveMQTTSource(): Promise<void> {
|
|||||||
|
|
||||||
export async function editMQTTSource(sourceId: string): Promise<void> {
|
export async function editMQTTSource(sourceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`);
|
const data = await apiGet<MQTTSource>(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('mqtt_source.error.load'));
|
|
||||||
const data = await resp.json();
|
|
||||||
await showMQTTSourceModal(data);
|
await showMQTTSourceModal(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
@@ -180,12 +173,9 @@ export async function editMQTTSource(sourceId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function cloneMQTTSource(sourceId: string): Promise<void> {
|
export async function cloneMQTTSource(sourceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`);
|
const data = await apiGet<MQTTSource>(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('mqtt_source.error.load'));
|
const { id: _omit, ...rest } = data;
|
||||||
const data = await resp.json();
|
await showMQTTSourceModal({ ...rest, name: `${data.name} (copy)` } as MQTTSource);
|
||||||
delete data.id;
|
|
||||||
data.name = data.name + ' (copy)';
|
|
||||||
await showMQTTSourceModal(data);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
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'));
|
const confirmed = await showConfirm(t('mqtt_source.delete.confirm'));
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`, { method: 'DELETE' });
|
await apiDelete(`/mqtt/sources/${sourceId}`);
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
showToast(t('mqtt_source.deleted'), 'success');
|
showToast(t('mqtt_source.deleted'), 'success');
|
||||||
mqttSourcesCache.invalidate();
|
mqttSourcesCache.invalidate();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -219,9 +205,7 @@ export async function testMQTTSource(): Promise<void> {
|
|||||||
if (testBtn) testBtn.classList.add('loading');
|
if (testBtn) testBtn.classList.add('loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/mqtt/sources/${id}/test`, { method: 'POST' });
|
const data = await apiPost<MQTTTestResult>(`/mqtt/sources/${id}/test`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(t('mqtt_source.test.success'), 'success');
|
showToast(t('mqtt_source.test.success'), 'success');
|
||||||
} else {
|
} 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> {
|
async function _testMQTTSourceFromCard(sourceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}/test`, { method: 'POST' });
|
const data = await apiPost<MQTTTestResult>(`/mqtt/sources/${sourceId}/test`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(t('mqtt_source.test.success'), 'success');
|
showToast(t('mqtt_source.test.success'), 'success');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* settings.
|
* settings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchWithAuth } from '../core/api.ts';
|
import { apiGet, apiPut } from '../core/api-client.ts';
|
||||||
import { showToast } from '../core/ui.ts';
|
import { showToast } from '../core/ui.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { logError } from '../core/log.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. */
|
/** Pull the latest prefs from the server and cache them. */
|
||||||
export async function refreshNotificationPreferences(): Promise<NotificationPreferences> {
|
export async function refreshNotificationPreferences(): Promise<NotificationPreferences> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/preferences/notifications');
|
const data = await apiGet<any>('/preferences/notifications');
|
||||||
if (!resp.ok) return _prefs;
|
|
||||||
const data = await resp.json();
|
|
||||||
_prefs = { ...DEFAULT_PREFS, ...data, channels: { ...DEFAULT_PREFS.channels, ...(data.channels || {}) } };
|
_prefs = { ...DEFAULT_PREFS, ...data, channels: { ...DEFAULT_PREFS.channels, ...(data.channels || {}) } };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('notifications.fetch', err);
|
logError('notifications.fetch', err);
|
||||||
@@ -116,15 +114,7 @@ export async function refreshNotificationPreferences(): Promise<NotificationPref
|
|||||||
export async function saveNotificationPreferences(
|
export async function saveNotificationPreferences(
|
||||||
next: NotificationPreferences,
|
next: NotificationPreferences,
|
||||||
): Promise<NotificationPreferences> {
|
): Promise<NotificationPreferences> {
|
||||||
const resp = await fetchWithAuth('/preferences/notifications', {
|
const saved = await apiPut<any>('/preferences/notifications', next);
|
||||||
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();
|
|
||||||
_prefs = { ...DEFAULT_PREFS, ...saved, channels: { ...DEFAULT_PREFS.channels, ...(saved.channels || {}) } };
|
_prefs = { ...DEFAULT_PREFS, ...saved, channels: { ...DEFAULT_PREFS.channels, ...(saved.channels || {}) } };
|
||||||
return _prefs;
|
return _prefs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
PATTERN_RECT_BORDERS,
|
PATTERN_RECT_BORDERS,
|
||||||
streamsCache,
|
streamsCache,
|
||||||
} from '../core/state.ts';
|
} 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 { patternTemplatesCache } from '../core/state.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||||
@@ -260,20 +261,10 @@ export async function savePatternTemplate(): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
|
||||||
if (templateId) {
|
if (templateId) {
|
||||||
response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
|
await apiPut(`/pattern-templates/${templateId}`, payload, { errorMessage: t('pattern.error.save_failed') });
|
||||||
method: 'PUT', body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
response = await fetchWithAuth('/pattern-templates', {
|
await apiPost('/pattern-templates', payload, { errorMessage: t('pattern.error.save_failed') });
|
||||||
method: 'POST', body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.json();
|
|
||||||
throw new Error(err.detail || 'Failed to save');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
|
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
|
||||||
@@ -305,20 +296,13 @@ export async function deletePatternTemplate(templateId: string): Promise<void> {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
|
await apiDelete(`/pattern-templates/${templateId}`, { errorMessage: t('pattern.error.delete_failed') });
|
||||||
method: 'DELETE',
|
showToast(t('pattern.deleted'), 'success');
|
||||||
});
|
patternTemplatesCache.invalidate();
|
||||||
if (response.ok) {
|
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||||
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');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.isAuth) return;
|
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.
|
* 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 { t } from '../core/i18n.ts';
|
||||||
import { dashboardPollInterval } from '../core/state.ts';
|
import { dashboardPollInterval } from '../core/state.ts';
|
||||||
import { isActiveTab } from '../core/tab-registry.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> {
|
async function _fetchPerformance(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/system/performance');
|
const data = await apiGet<any>('/system/performance');
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
_lastFetchData = data;
|
_lastFetchData = data;
|
||||||
_applyPerfDataToDom(data, /*pushHistory=*/true);
|
_applyPerfDataToDom(data, /*pushHistory=*/true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* Rendered as a CardSection inside the Automations tab, plus dashboard compact cards.
|
* 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 { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.ts';
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
@@ -135,10 +136,8 @@ export const csScenes = new CardSection('scenes', {
|
|||||||
bulkActions: [{
|
bulkActions: [{
|
||||||
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete',
|
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete',
|
||||||
handler: async (ids) => {
|
handler: async (ids) => {
|
||||||
const results = await Promise.allSettled(ids.map(id =>
|
const results = await Promise.allSettled(ids.map(id => apiDelete(`/scene-presets/${id}`)));
|
||||||
fetchWithAuth(`/scene-presets/${id}`, { method: 'DELETE' })
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
));
|
|
||||||
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
|
|
||||||
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||||
else showToast(t('scenes.deleted'), 'success');
|
else showToast(t('scenes.deleted'), 'success');
|
||||||
scenePresetsCache.invalidate();
|
scenePresetsCache.invalidate();
|
||||||
@@ -384,37 +383,22 @@ export async function saveScenePreset(): Promise<void> {
|
|||||||
const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : [];
|
const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : [];
|
||||||
|
|
||||||
try {
|
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) {
|
if (_editingId) {
|
||||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
await apiPut(`/scene-presets/${_editingId}`, body, { errorMessage: t('scenes.error.save_failed') });
|
||||||
.map(el => (el as HTMLElement).dataset.targetId);
|
|
||||||
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
await apiPost('/scene-presets', body, { errorMessage: t('scenes.error.save_failed') });
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scenePresetModal.forceClose();
|
scenePresetModal.forceClose();
|
||||||
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
||||||
scenePresetsCache.invalidate();
|
scenePresetsCache.invalidate();
|
||||||
_reloadScenesTab();
|
_reloadScenesTab();
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
errorEl.textContent = t('scenes.error.save_failed');
|
errorEl.textContent = error.message || t('scenes.error.save_failed');
|
||||||
errorEl.style.display = 'block';
|
errorEl.style.display = 'block';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,17 +472,9 @@ export async function addSceneTarget(): Promise<void> {
|
|||||||
|
|
||||||
export async function activateScenePreset(presetId: string): Promise<void> {
|
export async function activateScenePreset(presetId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, {
|
const result = await apiPost<{ status: string; errors: any[] }>(
|
||||||
method: 'POST',
|
`/scene-presets/${presetId}/activate`, undefined, { errorMessage: t('scenes.error.activate_failed') },
|
||||||
});
|
);
|
||||||
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();
|
|
||||||
if (result.status === 'activated') {
|
if (result.status === 'activated') {
|
||||||
showToast(t('scenes.activated'), 'success');
|
showToast(t('scenes.activated'), 'success');
|
||||||
} else {
|
} else {
|
||||||
@@ -507,7 +483,7 @@ export async function activateScenePreset(presetId: string): Promise<void> {
|
|||||||
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.isAuth) return;
|
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;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, {
|
await apiPost(`/scene-presets/${presetId}/recapture`, undefined, { errorMessage: t('scenes.error.recapture_failed') });
|
||||||
method: 'POST',
|
showToast(t('scenes.recaptured'), 'success');
|
||||||
});
|
scenePresetsCache.invalidate();
|
||||||
if (resp.ok) {
|
_reloadScenesTab();
|
||||||
showToast(t('scenes.recaptured'), 'success');
|
} catch (error: any) {
|
||||||
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) {
|
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
showToast(error.message || t('scenes.error.recapture_failed'), 'error');
|
showToast(error.message || t('scenes.error.recapture_failed'), 'error');
|
||||||
}
|
}
|
||||||
@@ -592,20 +559,11 @@ export async function deleteScenePreset(presetId: string): Promise<void> {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/scene-presets/${presetId}`, {
|
await apiDelete(`/scene-presets/${presetId}`, { errorMessage: t('scenes.error.delete_failed') });
|
||||||
method: 'DELETE',
|
showToast(t('scenes.deleted'), 'success');
|
||||||
});
|
scenePresetsCache.invalidate();
|
||||||
if (resp.ok) {
|
_reloadScenesTab();
|
||||||
showToast(t('scenes.deleted'), 'success');
|
} catch (error: any) {
|
||||||
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) {
|
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
showToast(error.message || t('scenes.error.delete_failed'), 'error');
|
showToast(error.message || t('scenes.error.delete_failed'), 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
audioTemplatesCache,
|
audioTemplatesCache,
|
||||||
apiKey,
|
apiKey,
|
||||||
} from '../core/state.ts';
|
} 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 { logError } from '../core/log.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
@@ -61,9 +62,7 @@ const audioTemplateModal = new AudioTemplateModal();
|
|||||||
|
|
||||||
async function loadAvailableAudioEngines() {
|
async function loadAvailableAudioEngines() {
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth('/audio-engines');
|
const data = await apiGet<{ engines?: any[] }>('/audio-engines');
|
||||||
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
|
|
||||||
const data = await response.json();
|
|
||||||
setAvailableAudioEngines(data.engines || []);
|
setAvailableAudioEngines(data.engines || []);
|
||||||
|
|
||||||
const select = document.getElementById('audio-template-engine') as HTMLSelectElement;
|
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) {
|
export async function editAudioTemplate(templateId: any) {
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`/audio-templates/${templateId}`);
|
const template = await apiGet<any>(`/audio-templates/${templateId}`);
|
||||||
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
|
|
||||||
const template = await response.json();
|
|
||||||
|
|
||||||
setCurrentEditingAudioTemplateId(templateId);
|
setCurrentEditingAudioTemplateId(templateId);
|
||||||
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`;
|
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() : [] };
|
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
|
||||||
if (templateId) {
|
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 {
|
} else {
|
||||||
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
|
await apiPost('/audio-templates', payload, { errorMessage: t('audio_template.error.save_failed') });
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || error.message || 'Failed to save audio template');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
|
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
|
||||||
@@ -312,11 +303,7 @@ export async function deleteAudioTemplate(templateId: any) {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
|
await apiDelete(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.delete') });
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || error.message || 'Failed to delete audio template');
|
|
||||||
}
|
|
||||||
showToast(t('audio_template.deleted'), 'success');
|
showToast(t('audio_template.deleted'), 'success');
|
||||||
audioTemplatesCache.invalidate();
|
audioTemplatesCache.invalidate();
|
||||||
await loadAudioTemplates();
|
await loadAudioTemplates();
|
||||||
@@ -328,11 +315,9 @@ export async function deleteAudioTemplate(templateId: any) {
|
|||||||
|
|
||||||
export async function cloneAudioTemplate(templateId: any) {
|
export async function cloneAudioTemplate(templateId: any) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
|
const tmpl = await apiGet<any>(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.load_failed') });
|
||||||
if (!resp.ok) throw new Error('Failed to load audio template');
|
|
||||||
const tmpl = await resp.json();
|
|
||||||
showAddAudioTemplateModal(tmpl);
|
showAddAudioTemplateModal(tmpl);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
console.error('Failed to clone audio template:', error);
|
console.error('Failed to clone audio template:', error);
|
||||||
showToast(t('audio_template.error.clone_failed'), '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
|
// Load audio devices for picker — filter by engine type
|
||||||
const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement;
|
const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/audio-devices');
|
const data = await apiGet<{ by_engine?: Record<string, any[]>; devices?: any[] }>('/audio-devices');
|
||||||
if (resp.ok) {
|
// Use engine-specific device list if available, fall back to flat list
|
||||||
const data = await resp.json();
|
const devices = (engineType && data.by_engine && data.by_engine[engineType])
|
||||||
// Use engine-specific device list if available, fall back to flat list
|
? data.by_engine[engineType]
|
||||||
const devices = (engineType && data.by_engine && data.by_engine[engineType])
|
: (data.devices || []);
|
||||||
? data.by_engine[engineType]
|
deviceSelect.innerHTML = devices.map(d => {
|
||||||
: (data.devices || []);
|
const label = d.name;
|
||||||
deviceSelect.innerHTML = devices.map(d => {
|
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||||||
const label = d.name;
|
return `<option value="${val}">${escapeHtml(label)}</option>`;
|
||||||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
}).join('');
|
||||||
return `<option value="${val}">${escapeHtml(label)}</option>`;
|
if (devices.length === 0) {
|
||||||
}).join('');
|
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||||||
if (devices.length === 0) {
|
|
||||||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
captureTemplatesCache, displaysCache, enginesCache,
|
captureTemplatesCache, displaysCache, enginesCache,
|
||||||
apiKey,
|
apiKey,
|
||||||
} from '../core/state.ts';
|
} 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 { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm, openLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.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) {
|
export async function editTemplate(templateId: any) {
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
|
const template = await apiGet<any>(`/capture-templates/${templateId}`);
|
||||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
|
||||||
const template = await response.json();
|
|
||||||
|
|
||||||
setCurrentEditingTemplateId(templateId);
|
setCurrentEditingTemplateId(templateId);
|
||||||
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
|
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
|
// Always refetch for engines with own displays (devices may change); use cache for desktop
|
||||||
if (!_cachedDisplays || engineHasOwnDisplays) {
|
if (!_cachedDisplays || engineHasOwnDisplays) {
|
||||||
const response = await fetchWithAuth(url);
|
const displaysData = await apiGet<{ displays?: any[] }>(url);
|
||||||
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
|
|
||||||
const displaysData = await response.json();
|
|
||||||
displaysCache.update(displaysData.displays || []);
|
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() : [] };
|
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
|
||||||
if (templateId) {
|
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 {
|
} else {
|
||||||
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
|
await apiPost('/capture-templates', payload, { errorMessage: t('templates.error.save_failed') });
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || error.message || 'Failed to save template');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
||||||
@@ -635,11 +626,7 @@ export async function deleteTemplate(templateId: any) {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
|
await apiDelete(`/capture-templates/${templateId}`, { errorMessage: t('templates.error.delete') });
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
|
||||||
}
|
|
||||||
showToast(t('templates.deleted'), 'success');
|
showToast(t('templates.deleted'), 'success');
|
||||||
captureTemplatesCache.invalidate();
|
captureTemplatesCache.invalidate();
|
||||||
await loadCaptureTemplates();
|
await loadCaptureTemplates();
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { _cachedSyncClocks, syncClocksCache } from '../core/state.ts';
|
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 { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.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() : [] };
|
const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const method = id ? 'PUT' : 'POST';
|
if (id) {
|
||||||
const url = id ? `/sync-clocks/${id}` : '/sync-clocks';
|
await apiPut(`/sync-clocks/${id}`, payload);
|
||||||
const resp = await fetchWithAuth(url, {
|
} else {
|
||||||
method,
|
await apiPost('/sync-clocks', payload);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
|
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
|
||||||
syncClockModal.forceClose();
|
syncClockModal.forceClose();
|
||||||
@@ -149,9 +144,7 @@ export async function saveSyncClock(): Promise<void> {
|
|||||||
|
|
||||||
export async function editSyncClock(clockId: string): Promise<void> {
|
export async function editSyncClock(clockId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
|
const data = await apiGet<SyncClock>(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
|
|
||||||
const data = await resp.json();
|
|
||||||
await showSyncClockModal(data);
|
await showSyncClockModal(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
@@ -161,12 +154,9 @@ export async function editSyncClock(clockId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function cloneSyncClock(clockId: string): Promise<void> {
|
export async function cloneSyncClock(clockId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
|
const data = await apiGet<SyncClock>(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
|
const { id: _omit, ...rest } = data;
|
||||||
const data = await resp.json();
|
await showSyncClockModal({ ...rest, name: `${data.name} (copy)` } as SyncClock);
|
||||||
delete data.id;
|
|
||||||
data.name = data.name + ' (copy)';
|
|
||||||
await showSyncClockModal(data);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
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'));
|
const confirmed = await showConfirm(t('sync_clock.delete.confirm'));
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`, { method: 'DELETE' });
|
await apiDelete(`/sync-clocks/${clockId}`);
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
showToast(t('sync_clock.deleted'), 'success');
|
showToast(t('sync_clock.deleted'), 'success');
|
||||||
syncClocksCache.invalidate();
|
syncClocksCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
@@ -195,8 +181,7 @@ export async function deleteSyncClock(clockId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function pauseSyncClock(clockId: string): Promise<void> {
|
export async function pauseSyncClock(clockId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
|
await apiPost(`/sync-clocks/${clockId}/pause`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
showToast(t('sync_clock.paused'), 'success');
|
showToast(t('sync_clock.paused'), 'success');
|
||||||
syncClocksCache.invalidate();
|
syncClocksCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
@@ -208,8 +193,7 @@ export async function pauseSyncClock(clockId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function resumeSyncClock(clockId: string): Promise<void> {
|
export async function resumeSyncClock(clockId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
|
await apiPost(`/sync-clocks/${clockId}/resume`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
showToast(t('sync_clock.resumed'), 'success');
|
showToast(t('sync_clock.resumed'), 'success');
|
||||||
syncClocksCache.invalidate();
|
syncClocksCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
@@ -221,8 +205,7 @@ export async function resumeSyncClock(clockId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function resetSyncClock(clockId: string): Promise<void> {
|
export async function resetSyncClock(clockId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
|
await apiPost(`/sync-clocks/${clockId}/reset`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
showToast(t('sync_clock.reset_done'), 'success');
|
showToast(t('sync_clock.reset_done'), 'success');
|
||||||
syncClocksCache.invalidate();
|
syncClocksCache.invalidate();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Auto-update — check for new releases, show banner, manage settings.
|
* 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 { showToast, showConfirm } from '../core/ui.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { IconSelect } from '../core/icon-select.ts';
|
import { IconSelect } from '../core/icon-select.ts';
|
||||||
@@ -129,10 +129,7 @@ export function dismissUpdate(): void {
|
|||||||
_hideBanner();
|
_hideBanner();
|
||||||
_setVersionBadgeUpdate(false);
|
_setVersionBadgeUpdate(false);
|
||||||
|
|
||||||
fetchWithAuth('/system/update/dismiss', {
|
apiPost('/system/update/dismiss', { version }).catch(() => {});
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ version }),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Apply update ───────────────────────────────────────────
|
// ─── Apply update ───────────────────────────────────────────
|
||||||
@@ -151,14 +148,7 @@ export async function applyUpdate(): Promise<void> {
|
|||||||
btns.forEach(b => (b as HTMLButtonElement).disabled = true);
|
btns.forEach(b => (b as HTMLButtonElement).disabled = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/system/update/apply', {
|
await apiPost('/system/update/apply', undefined, { timeout: 600000 /* 10 min for download + 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}`);
|
|
||||||
}
|
|
||||||
// Server will shut down — the frontend reconnect overlay handles the rest
|
// Server will shut down — the frontend reconnect overlay handles the rest
|
||||||
showToast(t('update.applying'), 'info');
|
showToast(t('update.applying'), 'info');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -171,9 +161,7 @@ export async function applyUpdate(): Promise<void> {
|
|||||||
|
|
||||||
export async function loadUpdateStatus(): Promise<void> {
|
export async function loadUpdateStatus(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/system/update/status');
|
const status = await apiGet<UpdateStatus>('/system/update/status');
|
||||||
if (!resp.ok) return;
|
|
||||||
const status: UpdateStatus = await resp.json();
|
|
||||||
_lastStatus = status;
|
_lastStatus = status;
|
||||||
_applyStatus(status);
|
_applyStatus(status);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -260,12 +248,7 @@ export async function checkForUpdates(): Promise<void> {
|
|||||||
if (spinner) spinner.style.display = '';
|
if (spinner) spinner.style.display = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/system/update/check', { method: 'POST' });
|
const status = await apiPost<UpdateStatus>('/system/update/check');
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
const status: UpdateStatus = await resp.json();
|
|
||||||
_lastStatus = status;
|
_lastStatus = status;
|
||||||
_applyStatus(status);
|
_applyStatus(status);
|
||||||
|
|
||||||
@@ -350,9 +333,7 @@ export function initUpdateSettingsPanel(): void {
|
|||||||
|
|
||||||
export async function loadUpdateSettings(): Promise<void> {
|
export async function loadUpdateSettings(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/system/update/settings');
|
const data = await apiGet<{ enabled: boolean; check_interval_hours: number; include_prerelease: boolean }>('/system/update/settings');
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
|
const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
|
||||||
const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | 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;
|
if (Number.isNaN(check_interval_hours)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/system/update/settings', {
|
await apiPut('/system/update/settings', { enabled, check_interval_hours, include_prerelease });
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
|
showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { _cachedWeatherSources, weatherSourcesCache } from '../core/state.ts';
|
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 { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.ts';
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
@@ -166,16 +167,10 @@ export async function saveWeatherSource(): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const method = id ? 'PUT' : 'POST';
|
if (id) {
|
||||||
const url = id ? `/weather-sources/${id}` : '/weather-sources';
|
await apiPut(`/weather-sources/${id}`, payload);
|
||||||
const resp = await fetchWithAuth(url, {
|
} else {
|
||||||
method,
|
await apiPost('/weather-sources', payload);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success');
|
showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success');
|
||||||
weatherSourceModal.forceClose();
|
weatherSourceModal.forceClose();
|
||||||
@@ -191,9 +186,7 @@ export async function saveWeatherSource(): Promise<void> {
|
|||||||
|
|
||||||
export async function editWeatherSource(sourceId: string): Promise<void> {
|
export async function editWeatherSource(sourceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
|
const data = await apiGet<WeatherSource>(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('weather_source.error.load'));
|
|
||||||
const data = await resp.json();
|
|
||||||
await showWeatherSourceModal(data);
|
await showWeatherSourceModal(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
@@ -203,12 +196,9 @@ export async function editWeatherSource(sourceId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function cloneWeatherSource(sourceId: string): Promise<void> {
|
export async function cloneWeatherSource(sourceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
|
const data = await apiGet<WeatherSource>(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') });
|
||||||
if (!resp.ok) throw new Error(t('weather_source.error.load'));
|
const { id: _omit, ...rest } = data;
|
||||||
const data = await resp.json();
|
await showWeatherSourceModal({ ...rest, name: `${data.name} (copy)` } as WeatherSource);
|
||||||
delete data.id;
|
|
||||||
data.name = data.name + ' (copy)';
|
|
||||||
await showWeatherSourceModal(data);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
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'));
|
const confirmed = await showConfirm(t('weather_source.delete.confirm'));
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`, { method: 'DELETE' });
|
await apiDelete(`/weather-sources/${sourceId}`);
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
showToast(t('weather_source.deleted'), 'success');
|
showToast(t('weather_source.deleted'), 'success');
|
||||||
weatherSourcesCache.invalidate();
|
weatherSourcesCache.invalidate();
|
||||||
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
||||||
@@ -243,9 +229,7 @@ export async function testWeatherSource(): Promise<void> {
|
|||||||
if (testBtn) testBtn.classList.add('loading');
|
if (testBtn) testBtn.classList.add('loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/weather-sources/${id}/test`, { method: 'POST' });
|
const data = await apiPost<WeatherTestResult>(`/weather-sources/${id}/test`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
|
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
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 ──
|
// ── Geolocation ──
|
||||||
|
|
||||||
export function weatherSourceGeolocate(): void {
|
export function weatherSourceGeolocate(): void {
|
||||||
@@ -336,9 +327,7 @@ const _weatherSourceActions: Record<string, (id: string) => void> = {
|
|||||||
|
|
||||||
async function _testWeatherSourceFromCard(sourceId: string): Promise<void> {
|
async function _testWeatherSourceFromCard(sourceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/weather-sources/${sourceId}/test`, { method: 'POST' });
|
const data = await apiPost<WeatherTestResult>(`/weather-sources/${sourceId}/test`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
|
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
colorStripSourcesCache, mqttSourcesCache,
|
colorStripSourcesCache, mqttSourcesCache,
|
||||||
outputTargetsCache, valueSourcesCache,
|
outputTargetsCache, valueSourcesCache,
|
||||||
} from '../core/state.ts';
|
} 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 { logError } from '../core/log.ts';
|
||||||
import { safeJsonParse } from '../core/storage.ts';
|
import { safeJsonParse } from '../core/storage.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
@@ -303,9 +304,7 @@ export async function showZ2MLightEditor(targetId: string | null = null, cloneDa
|
|||||||
let editData: any = null;
|
let editData: any = null;
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
editData = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||||
if (!resp.ok) throw new Error('Failed to load target');
|
|
||||||
editData = await resp.json();
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@@ -463,13 +462,10 @@ export async function saveZ2MLightEditor(): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = targetId
|
if (targetId) {
|
||||||
? await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload) })
|
await apiPut(`/output-targets/${targetId}`, payload);
|
||||||
: await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload) });
|
} else {
|
||||||
|
await apiPost('/output-targets', payload);
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(err.detail || `HTTP ${response.status}`);
|
|
||||||
}
|
}
|
||||||
showToast(targetId ? t('z2m_light.updated') : t('z2m_light.created'), 'success');
|
showToast(targetId ? t('z2m_light.updated') : t('z2m_light.created'), 'success');
|
||||||
outputTargetsCache.invalidate();
|
outputTargetsCache.invalidate();
|
||||||
@@ -489,12 +485,9 @@ export async function editZ2MLightTarget(targetId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function cloneZ2MLightTarget(targetId: string): Promise<void> {
|
export async function cloneZ2MLightTarget(targetId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
const data = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
|
||||||
if (!resp.ok) throw new Error('Failed to load target');
|
const { id: _omit, ...rest } = data;
|
||||||
const data = await resp.json();
|
await showZ2MLightEditor(null, { ...rest, name: `${data.name} (copy)` });
|
||||||
delete data.id;
|
|
||||||
data.name = data.name + ' (copy)';
|
|
||||||
await showZ2MLightEditor(null, data);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
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> {
|
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' });
|
await apiPost(`/output-targets/${targetId}/${action}`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
outputTargetsCache.invalidate();
|
outputTargetsCache.invalidate();
|
||||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||||
} catch (e: any) {
|
} 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?');
|
const confirmed = await showConfirm(t('confirm.turn_off_z2m_light') || 'Turn off mapped bulbs?');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/output-targets/${targetId}/z2m-light/turn-off`, { method: 'POST' });
|
await apiPost(`/output-targets/${targetId}/z2m-light/turn-off`, undefined, {
|
||||||
if (resp.ok) {
|
errorMessage: t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs',
|
||||||
showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success');
|
});
|
||||||
} else {
|
showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success');
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
showToast(err.detail || t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error');
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAuth) return;
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+130
-1084
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
|
||||||
|
* event→effect 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;
|
||||||
|
}
|
||||||
@@ -125,6 +125,8 @@
|
|||||||
"templates.error.engines": "Failed to load engines",
|
"templates.error.engines": "Failed to load engines",
|
||||||
"templates.error.required": "Please fill in all required fields",
|
"templates.error.required": "Please fill in all required fields",
|
||||||
"templates.error.delete": "Failed to delete template",
|
"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.title": "Test Capture",
|
||||||
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
|
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
|
||||||
"templates.test.display": "Display:",
|
"templates.test.display": "Display:",
|
||||||
@@ -1283,6 +1285,10 @@
|
|||||||
"automations.deleted": "Automation deleted",
|
"automations.deleted": "Automation deleted",
|
||||||
"automations.error.name_required": "Name is required",
|
"automations.error.name_required": "Name is required",
|
||||||
"automations.error.clone_failed": "Failed to clone automation",
|
"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.title": "Scenes",
|
||||||
"scenes.add": "Capture Scene",
|
"scenes.add": "Capture Scene",
|
||||||
"scenes.edit": "Edit Scene",
|
"scenes.edit": "Edit Scene",
|
||||||
@@ -1824,6 +1830,8 @@
|
|||||||
"audio_template.error.engines": "Failed to load audio engines",
|
"audio_template.error.engines": "Failed to load audio engines",
|
||||||
"audio_template.error.required": "Please fill in all required fields",
|
"audio_template.error.required": "Please fill in all required fields",
|
||||||
"audio_template.error.delete": "Failed to delete audio template",
|
"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.value": "Value Sources",
|
||||||
"streams.group.sync": "Sync Clocks",
|
"streams.group.sync": "Sync Clocks",
|
||||||
"streams.group.gradients": "Gradients",
|
"streams.group.gradients": "Gradients",
|
||||||
@@ -1843,6 +1851,7 @@
|
|||||||
"gradient.error.name_required": "Name is required",
|
"gradient.error.name_required": "Name is required",
|
||||||
"gradient.error.min_stops": "At least 2 color stops are required",
|
"gradient.error.min_stops": "At least 2 color stops are required",
|
||||||
"gradient.error.delete_failed": "Failed to delete gradient",
|
"gradient.error.delete_failed": "Failed to delete gradient",
|
||||||
|
"gradient.error.save_failed": "Failed to save gradient",
|
||||||
"gradient.create_name": "New gradient name:",
|
"gradient.create_name": "New gradient name:",
|
||||||
"gradient.edit_name": "Rename gradient:",
|
"gradient.edit_name": "Rename gradient:",
|
||||||
"gradient.confirm_delete": "Delete gradient \"{name}\"?",
|
"gradient.confirm_delete": "Delete gradient \"{name}\"?",
|
||||||
@@ -2208,6 +2217,7 @@
|
|||||||
"device.error.update": "Failed to update device",
|
"device.error.update": "Failed to update device",
|
||||||
"device.error.save": "Failed to save settings",
|
"device.error.save": "Failed to save settings",
|
||||||
"device.error.clone_failed": "Failed to clone device",
|
"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.error.fill_all_fields": "Please fill in all fields",
|
||||||
"device_discovery.added": "Device added successfully",
|
"device_discovery.added": "Device added successfully",
|
||||||
"device_discovery.error.add_failed": "Failed to add device",
|
"device_discovery.error.add_failed": "Failed to add device",
|
||||||
@@ -2249,6 +2259,7 @@
|
|||||||
"target.error.stop_failed": "Failed to stop target",
|
"target.error.stop_failed": "Failed to stop target",
|
||||||
"target.error.clone_failed": "Failed to clone target",
|
"target.error.clone_failed": "Failed to clone target",
|
||||||
"target.error.delete_failed": "Failed to delete 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.button": "Stop All",
|
||||||
"targets.stop_all.none_running": "No targets are currently running",
|
"targets.stop_all.none_running": "No targets are currently running",
|
||||||
"targets.stop_all.stopped": "Stopped {count} target(s)",
|
"targets.stop_all.stopped": "Stopped {count} target(s)",
|
||||||
@@ -2263,6 +2274,7 @@
|
|||||||
"pattern.error.clone_failed": "Failed to clone pattern template",
|
"pattern.error.clone_failed": "Failed to clone pattern template",
|
||||||
"pattern.error.delete_failed": "Failed to delete pattern template",
|
"pattern.error.delete_failed": "Failed to delete pattern template",
|
||||||
"pattern.error.capture_bg_failed": "Failed to capture background",
|
"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_picture_failed": "Failed to clone picture source",
|
||||||
"stream.error.clone_capture_failed": "Failed to clone capture template",
|
"stream.error.clone_capture_failed": "Failed to clone capture template",
|
||||||
"stream.error.clone_pp_failed": "Failed to clone postprocessing 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.load": "Error loading audio processing template",
|
||||||
"audio_processing.error.delete": "Error deleting 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.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.filter_count": "Filter count",
|
||||||
"audio_processing.filters_label": "filters",
|
"audio_processing.filters_label": "filters",
|
||||||
"streams.group.audio_processing": "Audio Processing",
|
"streams.group.audio_processing": "Audio Processing",
|
||||||
|
|||||||
@@ -180,6 +180,8 @@
|
|||||||
"templates.error.engines": "Не удалось загрузить движки",
|
"templates.error.engines": "Не удалось загрузить движки",
|
||||||
"templates.error.required": "Пожалуйста, заполните все обязательные поля",
|
"templates.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||||
"templates.error.delete": "Не удалось удалить шаблон",
|
"templates.error.delete": "Не удалось удалить шаблон",
|
||||||
|
"templates.error.save_failed": "Не удалось сохранить шаблон",
|
||||||
|
"templates.error.load_failed": "Не удалось загрузить шаблон",
|
||||||
"templates.test.title": "Тест Захвата",
|
"templates.test.title": "Тест Захвата",
|
||||||
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
|
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
|
||||||
"templates.test.display": "Дисплей:",
|
"templates.test.display": "Дисплей:",
|
||||||
@@ -1317,6 +1319,10 @@
|
|||||||
"automations.deleted": "Автоматизация удалена",
|
"automations.deleted": "Автоматизация удалена",
|
||||||
"automations.error.name_required": "Введите название",
|
"automations.error.name_required": "Введите название",
|
||||||
"automations.error.clone_failed": "Не удалось клонировать автоматизацию",
|
"automations.error.clone_failed": "Не удалось клонировать автоматизацию",
|
||||||
|
"automations.error.load_failed": "Не удалось загрузить автоматизацию",
|
||||||
|
"automations.error.save_failed": "Не удалось сохранить автоматизацию",
|
||||||
|
"automations.error.delete_failed": "Не удалось удалить автоматизацию",
|
||||||
|
"automations.error.toggle_failed": "Не удалось переключить автоматизацию",
|
||||||
"scenes.title": "Сцены",
|
"scenes.title": "Сцены",
|
||||||
"scenes.add": "Захватить сцену",
|
"scenes.add": "Захватить сцену",
|
||||||
"scenes.edit": "Редактировать сцену",
|
"scenes.edit": "Редактировать сцену",
|
||||||
@@ -1789,6 +1795,10 @@
|
|||||||
"audio_template.error.engines": "Не удалось загрузить аудиодвижки",
|
"audio_template.error.engines": "Не удалось загрузить аудиодвижки",
|
||||||
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
|
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||||
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
|
"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.value": "Источники значений",
|
||||||
"streams.group.sync": "Часы синхронизации",
|
"streams.group.sync": "Часы синхронизации",
|
||||||
"tree.group.picture": "Источники изображений",
|
"tree.group.picture": "Источники изображений",
|
||||||
@@ -2067,6 +2077,7 @@
|
|||||||
"device.error.update": "Не удалось обновить устройство",
|
"device.error.update": "Не удалось обновить устройство",
|
||||||
"device.error.save": "Не удалось сохранить настройки",
|
"device.error.save": "Не удалось сохранить настройки",
|
||||||
"device.error.clone_failed": "Не удалось клонировать устройство",
|
"device.error.clone_failed": "Не удалось клонировать устройство",
|
||||||
|
"device.error.load_failed": "Не удалось загрузить устройство",
|
||||||
"device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля",
|
"device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля",
|
||||||
"device_discovery.added": "Устройство успешно добавлено",
|
"device_discovery.added": "Устройство успешно добавлено",
|
||||||
"device_discovery.error.add_failed": "Не удалось добавить устройство",
|
"device_discovery.error.add_failed": "Не удалось добавить устройство",
|
||||||
@@ -2108,6 +2119,7 @@
|
|||||||
"target.error.stop_failed": "Не удалось остановить цель",
|
"target.error.stop_failed": "Не удалось остановить цель",
|
||||||
"target.error.clone_failed": "Не удалось клонировать цель",
|
"target.error.clone_failed": "Не удалось клонировать цель",
|
||||||
"target.error.delete_failed": "Не удалось удалить цель",
|
"target.error.delete_failed": "Не удалось удалить цель",
|
||||||
|
"target.error.load_failed": "Не удалось загрузить цель",
|
||||||
"targets.stop_all.button": "Остановить все",
|
"targets.stop_all.button": "Остановить все",
|
||||||
"targets.stop_all.none_running": "Нет запущенных целей",
|
"targets.stop_all.none_running": "Нет запущенных целей",
|
||||||
"targets.stop_all.stopped": "Остановлено целей: {count}",
|
"targets.stop_all.stopped": "Остановлено целей: {count}",
|
||||||
@@ -2122,6 +2134,7 @@
|
|||||||
"pattern.error.clone_failed": "Не удалось клонировать шаблон узоров",
|
"pattern.error.clone_failed": "Не удалось клонировать шаблон узоров",
|
||||||
"pattern.error.delete_failed": "Не удалось удалить шаблон узоров",
|
"pattern.error.delete_failed": "Не удалось удалить шаблон узоров",
|
||||||
"pattern.error.capture_bg_failed": "Не удалось захватить фон",
|
"pattern.error.capture_bg_failed": "Не удалось захватить фон",
|
||||||
|
"pattern.error.save_failed": "Не удалось сохранить шаблон узоров",
|
||||||
"stream.error.clone_picture_failed": "Не удалось клонировать источник изображения",
|
"stream.error.clone_picture_failed": "Не удалось клонировать источник изображения",
|
||||||
"stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата",
|
"stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата",
|
||||||
"stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки",
|
"stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки",
|
||||||
@@ -2634,6 +2647,7 @@
|
|||||||
"audio_processing.error.load": "Ошибка загрузки шаблона обработки звука",
|
"audio_processing.error.load": "Ошибка загрузки шаблона обработки звука",
|
||||||
"audio_processing.error.delete": "Ошибка удаления шаблона обработки звука",
|
"audio_processing.error.delete": "Ошибка удаления шаблона обработки звука",
|
||||||
"audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука",
|
"audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука",
|
||||||
|
"audio_processing.error.save_failed": "Не удалось сохранить шаблон обработки звука",
|
||||||
"audio_processing.filter_count": "Количество фильтров",
|
"audio_processing.filter_count": "Количество фильтров",
|
||||||
"audio_processing.filters_label": "фильтров",
|
"audio_processing.filters_label": "фильтров",
|
||||||
"streams.group.audio_processing": "Обработка звука",
|
"streams.group.audio_processing": "Обработка звука",
|
||||||
|
|||||||
@@ -178,6 +178,8 @@
|
|||||||
"templates.error.engines": "加载引擎失败",
|
"templates.error.engines": "加载引擎失败",
|
||||||
"templates.error.required": "请填写所有必填项",
|
"templates.error.required": "请填写所有必填项",
|
||||||
"templates.error.delete": "删除模板失败",
|
"templates.error.delete": "删除模板失败",
|
||||||
|
"templates.error.save_failed": "保存模板失败",
|
||||||
|
"templates.error.load_failed": "加载模板失败",
|
||||||
"templates.test.title": "测试采集",
|
"templates.test.title": "测试采集",
|
||||||
"templates.test.description": "保存前测试此模板,查看采集预览和性能指标。",
|
"templates.test.description": "保存前测试此模板,查看采集预览和性能指标。",
|
||||||
"templates.test.display": "显示器:",
|
"templates.test.display": "显示器:",
|
||||||
@@ -1313,6 +1315,10 @@
|
|||||||
"automations.deleted": "自动化已删除",
|
"automations.deleted": "自动化已删除",
|
||||||
"automations.error.name_required": "名称为必填项",
|
"automations.error.name_required": "名称为必填项",
|
||||||
"automations.error.clone_failed": "克隆自动化失败",
|
"automations.error.clone_failed": "克隆自动化失败",
|
||||||
|
"automations.error.load_failed": "加载自动化失败",
|
||||||
|
"automations.error.save_failed": "保存自动化失败",
|
||||||
|
"automations.error.delete_failed": "删除自动化失败",
|
||||||
|
"automations.error.toggle_failed": "切换自动化失败",
|
||||||
"scenes.title": "场景",
|
"scenes.title": "场景",
|
||||||
"scenes.add": "捕获场景",
|
"scenes.add": "捕获场景",
|
||||||
"scenes.edit": "编辑场景",
|
"scenes.edit": "编辑场景",
|
||||||
@@ -1785,6 +1791,10 @@
|
|||||||
"audio_template.error.engines": "加载音频引擎失败",
|
"audio_template.error.engines": "加载音频引擎失败",
|
||||||
"audio_template.error.required": "请填写所有必填项",
|
"audio_template.error.required": "请填写所有必填项",
|
||||||
"audio_template.error.delete": "删除音频模板失败",
|
"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.value": "值源",
|
||||||
"streams.group.sync": "同步时钟",
|
"streams.group.sync": "同步时钟",
|
||||||
"tree.group.picture": "图片源",
|
"tree.group.picture": "图片源",
|
||||||
@@ -2063,6 +2073,7 @@
|
|||||||
"device.error.update": "更新设备失败",
|
"device.error.update": "更新设备失败",
|
||||||
"device.error.save": "保存设置失败",
|
"device.error.save": "保存设置失败",
|
||||||
"device.error.clone_failed": "克隆设备失败",
|
"device.error.clone_failed": "克隆设备失败",
|
||||||
|
"device.error.load_failed": "加载设备失败",
|
||||||
"device_discovery.error.fill_all_fields": "请填写所有字段",
|
"device_discovery.error.fill_all_fields": "请填写所有字段",
|
||||||
"device_discovery.added": "设备添加成功",
|
"device_discovery.added": "设备添加成功",
|
||||||
"device_discovery.error.add_failed": "添加设备失败",
|
"device_discovery.error.add_failed": "添加设备失败",
|
||||||
@@ -2104,6 +2115,7 @@
|
|||||||
"target.error.stop_failed": "停止目标失败",
|
"target.error.stop_failed": "停止目标失败",
|
||||||
"target.error.clone_failed": "克隆目标失败",
|
"target.error.clone_failed": "克隆目标失败",
|
||||||
"target.error.delete_failed": "删除目标失败",
|
"target.error.delete_failed": "删除目标失败",
|
||||||
|
"target.error.load_failed": "加载目标失败",
|
||||||
"targets.stop_all.button": "全部停止",
|
"targets.stop_all.button": "全部停止",
|
||||||
"targets.stop_all.none_running": "当前没有运行中的目标",
|
"targets.stop_all.none_running": "当前没有运行中的目标",
|
||||||
"targets.stop_all.stopped": "已停止 {count} 个目标",
|
"targets.stop_all.stopped": "已停止 {count} 个目标",
|
||||||
@@ -2118,6 +2130,7 @@
|
|||||||
"pattern.error.clone_failed": "克隆图案模板失败",
|
"pattern.error.clone_failed": "克隆图案模板失败",
|
||||||
"pattern.error.delete_failed": "删除图案模板失败",
|
"pattern.error.delete_failed": "删除图案模板失败",
|
||||||
"pattern.error.capture_bg_failed": "捕获背景失败",
|
"pattern.error.capture_bg_failed": "捕获背景失败",
|
||||||
|
"pattern.error.save_failed": "保存图案模板失败",
|
||||||
"stream.error.clone_picture_failed": "克隆图片源失败",
|
"stream.error.clone_picture_failed": "克隆图片源失败",
|
||||||
"stream.error.clone_capture_failed": "克隆捕获模板失败",
|
"stream.error.clone_capture_failed": "克隆捕获模板失败",
|
||||||
"stream.error.clone_pp_failed": "克隆后处理模板失败",
|
"stream.error.clone_pp_failed": "克隆后处理模板失败",
|
||||||
@@ -2628,6 +2641,7 @@
|
|||||||
"audio_processing.error.load": "加载音频处理模板时出错",
|
"audio_processing.error.load": "加载音频处理模板时出错",
|
||||||
"audio_processing.error.delete": "删除音频处理模板时出错",
|
"audio_processing.error.delete": "删除音频处理模板时出错",
|
||||||
"audio_processing.error.clone_failed": "克隆音频处理模板失败",
|
"audio_processing.error.clone_failed": "克隆音频处理模板失败",
|
||||||
|
"audio_processing.error.save_failed": "保存音频处理模板失败",
|
||||||
"audio_processing.filter_count": "过滤器数量",
|
"audio_processing.filter_count": "过滤器数量",
|
||||||
"audio_processing.filters_label": "个过滤器",
|
"audio_processing.filters_label": "个过滤器",
|
||||||
"streams.group.audio_processing": "音频处理",
|
"streams.group.audio_processing": "音频处理",
|
||||||
|
|||||||
Reference in New Issue
Block a user