Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17dd2e02ba | |||
| 7a12f39f49 | |||
| dd43f3836d | |||
| d32961085d | |||
| 6cd5e057da | |||
| 81b18089e1 | |||
| abc204c04e | |||
| 9550688c1e | |||
| 9dcd76d264 | |||
| 0409cd8b66 | |||
| 6180569b10 | |||
| f71e10ee06 | |||
| ca59546711 | |||
| 4a82595f26 | |||
| 1ada5ac334 | |||
| e18d56c838 | |||
| 7728aecb4f | |||
| e28ab5a956 | |||
| 1e395fd09e | |||
| ffee156c17 |
+69
-23
@@ -1,54 +1,100 @@
|
|||||||
## v0.8.1 (2026-05-28)
|
## v0.8.2 (2026-06-08)
|
||||||
|
|
||||||
### User-facing changes
|
### User-facing changes
|
||||||
|
|
||||||
#### Features
|
#### Features
|
||||||
|
|
||||||
##### Multi-broker MQTT devices
|
##### WLED native realtime UDP output
|
||||||
|
- New realtime UDP sink speaking WLED's **DRGB / DRGBW / DNRGB** protocols, with automatic revert to the device's prior state when streaming stops ([7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec))
|
||||||
|
|
||||||
- The device editor now shows an MQTT **broker picker** for `device_type=mqtt` (in both the add-device and device-settings modals), wired into load / save / validate / dirty-check / clone. An empty selection means "first available broker"
|
##### Automatic brightness limiting (ABL) / power budget
|
||||||
- `mqtt_source_id` is now threaded end-to-end through `DeviceCreate` / `DeviceUpdate` / `DeviceResponse` and the device routes; the referenced broker is validated on create **and** update ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Per-LED power budgeting that caps total draw by scaling brightness to a configurable current/PSU limit, preventing brownouts on long strips ([ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156))
|
||||||
|
|
||||||
##### Schema-driven wiring-graph editor
|
##### Scene playlists
|
||||||
|
- Scenes can be grouped into **playlists with timed auto-cycling**, so a target can rotate through looks on a schedule ([f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e))
|
||||||
|
- Playlist + cycling state is included in the aggregated `/snapshot` response ([abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c))
|
||||||
|
|
||||||
- The visual graph editor now renders ports and edges generically from a backend-served schema (`GET /api/v1/graph/schema`) instead of hard-coding the connectable-field topology in two places — so client and server can no longer drift
|
##### Auto edge-calibration + guided first-run setup wizard
|
||||||
- New `GET /api/v1/graph` returns the full nodes + edges + validation topology, and `GET /api/v1/graph/dependents/{kind}/{id}` reports what references an entity ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Backend core for **automatic screen-edge calibration** ([0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8)), a one-call setup scaffold with an onboarding flag ([9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d)), and a browser-driven calibration UI ([9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688))
|
||||||
|
- A **guided first-run setup wizard** ties it together for new installs ([81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808)), with all-provider source discovery and a spatial corner picker ([dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38))
|
||||||
|
|
||||||
##### Aggregated snapshot endpoint
|
##### Region-of-interest (ROI) screen capture
|
||||||
|
- Screen sampling can now be cropped to a **region of interest** instead of the whole display ([ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546))
|
||||||
|
|
||||||
- New `GET /api/v1/snapshot` returns all output targets (with processing state + metrics), devices (with brightness), the source / preset / clock lists, and the system block in a **single response** — collapsing the Home Assistant integration's previous ~2N+M request fan-out into one round trip
|
##### Built-in "look" presets
|
||||||
- `?include=` fetches only a subset of sections, and an excluded section also skips its server-side work (e.g. cold-cache hardware brightness probes or the blocking NVML performance query) ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- One-click looks: **Cinematic / Vivid / Cozy / Soft / Cool** ([e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c))
|
||||||
|
|
||||||
|
##### Weekday + timezone scheduling
|
||||||
|
- The time-of-day automation rule now supports **weekday selection and explicit timezones** ([1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac))
|
||||||
|
|
||||||
|
##### Value sources
|
||||||
|
- New **sandboxed-Jinja template combinator** for composing value sources ([6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9)) and optional normalization for magnitude sources ([669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20))
|
||||||
|
|
||||||
|
##### Visual graph editor
|
||||||
|
- The editor is now a **full wiring control surface** ([2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46)), and you can **duplicate a selected subgraph** server-side ([15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82))
|
||||||
|
|
||||||
|
##### Android on-device capture
|
||||||
|
- **System audio playback capture** ([fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1)), **OS notification capture** via NotificationListenerService ([0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83)), **webcam capture** via Camera2 ([4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6)), and a **foreground-app automation condition** ([1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2))
|
||||||
|
|
||||||
#### Bug Fixes
|
#### Bug Fixes
|
||||||
|
- **Security:** removed an active **weak default API key** from the shipped config — fresh installs no longer ship with a guessable key. Set your own key on first run ([5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5))
|
||||||
- **Graceful shutdown no longer hangs:** uvicorn's graceful-shutdown wait is now bounded (`GRACEFUL_SHUTDOWN_TIMEOUT`, shared by the desktop, Android, and demo launchers). A lingering events WebSocket (which the browser auto-reconnects) used to keep connections from draining, so the lifespan shutdown never ran — leaving LED targets lit and blocking process exit. Ctrl+C / OS shutdown with the UI open now reliably stops targets and checkpoints the DB ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Removed a broken legacy `/system/mqtt/settings` route ([fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201))
|
||||||
- **Device update error codes:** `update_device` no longer masks an intentional 4xx (e.g. an unknown `mqtt_source_id` or failed group validation) as a generic 500 ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Scene brightness value-source changes now sync to the live processor immediately ([02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3))
|
||||||
|
- Wizard hardening: scaffolded targets are registered with the ProcessorManager and the final review step is more robust ([6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05))
|
||||||
|
- Installer opens the WebUI only once after "Launch LedGrab" ([05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Development / Internal
|
### Development / Internal
|
||||||
|
|
||||||
#### Backend
|
#### Backend / Storage
|
||||||
|
- `clone()` is now gated behind an **opt-in allowlist**, with expanded duplicate-handling tests ([498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f))
|
||||||
- **Wiring-graph schema engine** (`api/graph_schema.py`): a pure, unit-tested module that is the single source of truth for which reference fields connect which entity kinds; builds the topology and performs dependency lookup plus cycle / dangling-reference detection without booting the app or any store. The route layer only gathers serialized entities and delegates ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
- **Structured access log:** a new middleware emits one structured line per request, attributing it to the authenticated token's friendly label (the key name, **never** the secret) so traffic can be traced to a client (e.g. `homeassistant` vs `android`). uvicorn's own access log is disabled to avoid duplicate lines ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
- Shared `validate_mqtt_source_exists` (`_mqtt_validation.py`) deduplicates the MQTT-source existence check between the device and output-target routes ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
- In-progress dashboard customization groundwork ([6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569))
|
||||||
|
|
||||||
- Service-worker refresh for the new bundle ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
#### Docs
|
||||||
|
- Actualized README + API reference with embedded screenshots ([12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6)), graph-editor wiring-control roadmap ([d505388](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d505388)), Android audio-capture design notes ([4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc)); removed stale ANDROID-REVIEW planning docs ([9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15))
|
||||||
|
|
||||||
#### Tests
|
#### Tests
|
||||||
|
- Large new suites for calibration solver/session (incl. adversarial), setup & scene-playlist routes, playlist engine, and ROI capture. Full suite: **2149 passing, 2 skipped**
|
||||||
- New suites: graph routes + schema engine, snapshot routes, access-log middleware, `mqtt_source_id` device regressions, and the bounded-shutdown entrypoint. Full suite: **1614 passing** ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits (1)</summary>
|
<summary>All Commits (31)</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| Hash | Message | Author |
|
||||||
| ---- | ------- | ------ |
|
| ---- | ------- | ------ |
|
||||||
| [a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba) | feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers | alexei.dolgolyov |
|
| [dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38) | fix(calibration-wizard): all-provider discovery + spatial corner picker | alexei.dolgolyov |
|
||||||
|
| [6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05) | fix(setup): register scaffolded target with ProcessorManager + final-review hardening | alexei.dolgolyov |
|
||||||
|
| [81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808) | feat(onboarding): guided first-run setup wizard (phase 4, final) | alexei.dolgolyov |
|
||||||
|
| [abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c) | feat(snapshot): include scene playlists + cycling state in snapshot | alexei.dolgolyov |
|
||||||
|
| [9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688) | feat(calibration): browser-driven auto edge-calibration UI (phase 3) | alexei.dolgolyov |
|
||||||
|
| [9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d) | feat(setup): one-call setup scaffold + onboarding flag (phase 2) | alexei.dolgolyov |
|
||||||
|
| [0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8) | feat(calibration): auto edge-calibration backend core (phase 1) | alexei.dolgolyov |
|
||||||
|
| [6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569) | wip(dashboard): in-progress dashboard customization changes | alexei.dolgolyov |
|
||||||
|
| [f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e) | feat(scenes): scene playlists with timed auto-cycling | alexei.dolgolyov |
|
||||||
|
| [ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546) | feat(capture): region-of-interest (ROI) crop for screen sampling | alexei.dolgolyov |
|
||||||
|
| [1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac) | feat(automations): weekday + timezone scheduling for time-of-day rule | alexei.dolgolyov |
|
||||||
|
| [e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c) | feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool) | alexei.dolgolyov |
|
||||||
|
| [7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec) | feat(wled): native realtime UDP output (DRGB/DRGBW/DNRGB) with auto-revert | alexei.dolgolyov |
|
||||||
|
| [ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156) | feat(targets): automatic brightness limiting (ABL) / per-LED power budget | alexei.dolgolyov |
|
||||||
|
| [02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3) | fix(scenes): sync brightness value-source change to live processor | alexei.dolgolyov |
|
||||||
|
| [fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201) | fix(api): remove broken legacy /system/mqtt/settings route | alexei.dolgolyov |
|
||||||
|
| [5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5) | fix(security): remove active weak default API key from shipped config | alexei.dolgolyov |
|
||||||
|
| [9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15) | docs(android): remove ANDROID-REVIEW planning/review docs | alexei.dolgolyov |
|
||||||
|
| [1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2) | feat(android): foreground-app automation condition | alexei.dolgolyov |
|
||||||
|
| [4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6) | feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine) | alexei.dolgolyov |
|
||||||
|
| [0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83) | feat(android): on-device OS notification capture (NotificationListenerService) | alexei.dolgolyov |
|
||||||
|
| [4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc) | docs(android): add audio-capture design + missing-functionality review | alexei.dolgolyov |
|
||||||
|
| [fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1) | feat(audio): Android on-device system playback capture | alexei.dolgolyov |
|
||||||
|
| [669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20) | feat(value-sources): optional normalization for magnitude sources | alexei.dolgolyov |
|
||||||
|
| [6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9) | feat(value-sources): add sandboxed-Jinja template combinator | alexei.dolgolyov |
|
||||||
|
| [12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6) | docs: actualize README and API reference, embed screenshots | alexei.dolgolyov |
|
||||||
|
| [498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f) | refactor(storage): gate clone() behind an opt-in allowlist; expand duplicate tests | alexei.dolgolyov |
|
||||||
|
| [15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82) | feat(graph): duplicate a selected subgraph server-side | alexei.dolgolyov |
|
||||||
|
| [2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46) | feat(graph): make the visual editor a full wiring control surface | alexei.dolgolyov |
|
||||||
|
| [05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121) | fix(installer): open WebUI once after "Launch LedGrab" | alexei.dolgolyov |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ android {
|
|||||||
// 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.8.1"
|
versionName = "0.8.2"
|
||||||
|
|
||||||
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
||||||
// ABI in only when the matching pydantic-core wheel is on disk —
|
// ABI in only when the matching pydantic-core wheel is on disk —
|
||||||
@@ -210,6 +210,10 @@ dependencies {
|
|||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
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")
|
||||||
|
// EncryptedSharedPreferences (Android Keystore-backed) for the per-install
|
||||||
|
// server API key (see ApiKeyManager). Falls back to plain SharedPreferences
|
||||||
|
// when the keystore is unavailable.
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
||||||
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
||||||
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
android:usesPermissionFlags="neverForLocation"
|
android:usesPermissionFlags="neverForLocation"
|
||||||
tools:targetApi="s" />
|
tools:targetApi="s" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
|
||||||
|
tools:targetApi="s" />
|
||||||
|
|
||||||
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.ledgrab.android
|
package com.ledgrab.android
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,8 +26,23 @@ import java.security.SecureRandom
|
|||||||
*/
|
*/
|
||||||
class ApiKeyManager(context: Context) {
|
class ApiKeyManager(context: Context) {
|
||||||
|
|
||||||
private val prefs = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
|
// Prefer Android-Keystore-backed EncryptedSharedPreferences for the API
|
||||||
|
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
|
||||||
|
// or absent keystore, or a key got corrupted), creation throws — fall back
|
||||||
|
// to plain SharedPreferences so a keystore failure NEVER bricks the local
|
||||||
|
// API key (which would 401 every LAN client). [encrypted] records which
|
||||||
|
// path we took so we don't repeatedly attempt migration.
|
||||||
|
private val encrypted: Boolean
|
||||||
|
private val prefs: SharedPreferences
|
||||||
|
|
||||||
|
init {
|
||||||
|
val (store, isEncrypted) = buildPrefs(appContext)
|
||||||
|
prefs = store
|
||||||
|
encrypted = isEncrypted
|
||||||
|
if (isEncrypted) migrateLegacyKeyIfPresent()
|
||||||
|
}
|
||||||
|
|
||||||
// Once we've materialised a key in this process, cache it so
|
// Once we've materialised a key in this process, cache it so
|
||||||
// subsequent reads don't hit prefs and don't risk re-checking
|
// subsequent reads don't hit prefs and don't risk re-checking
|
||||||
@@ -60,6 +78,20 @@ class ApiKeyManager(context: Context) {
|
|||||||
cached = existing
|
cached = existing
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
// Before minting a fresh key, fall back to any key still in the
|
||||||
|
// legacy plain store (covers a failed/partial encrypted migration:
|
||||||
|
// commit() can return false WITHOUT throwing, so migration may have
|
||||||
|
// left the live key only in the legacy file). Rotating the
|
||||||
|
// per-install key would 401 every already-paired client, so we
|
||||||
|
// generate a brand-new key ONLY when no key exists anywhere.
|
||||||
|
recoverLegacyKey()?.let { recovered ->
|
||||||
|
// Best-effort persist into the encrypted store; cache regardless
|
||||||
|
// so we still return the recovered key if the write keeps failing.
|
||||||
|
runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() }
|
||||||
|
cached = recovered
|
||||||
|
Log.i(TAG, "Recovered existing API key from legacy storage")
|
||||||
|
return recovered
|
||||||
|
}
|
||||||
val generated = generateKey()
|
val generated = generateKey()
|
||||||
// commit() (synchronous disk write) on the FIRST write so
|
// commit() (synchronous disk write) on the FIRST write so
|
||||||
// the key is durable before MainActivity encodes it into a
|
// the key is durable before MainActivity encodes it into a
|
||||||
@@ -74,6 +106,88 @@ class ApiKeyManager(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the backing store, preferring EncryptedSharedPreferences. Returns
|
||||||
|
* (store, isEncrypted). Any keystore failure falls back to the plain prefs
|
||||||
|
* file so the local API key is never lost on a broken-keystore device.
|
||||||
|
*/
|
||||||
|
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
|
||||||
|
return try {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
val store = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
ENCRYPTED_PREFS_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
store to true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Keystore unavailable/corrupt — degrade to plain prefs rather
|
||||||
|
// than crashing. Worst case the key is stored unencrypted on a
|
||||||
|
// single-user TV box, which is the pre-existing behaviour.
|
||||||
|
Log.w(TAG, "EncryptedSharedPreferences unavailable, using plain prefs: ${e.message}")
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration: if a key exists in the legacy plain-text prefs file
|
||||||
|
* (from before encrypted storage), copy it into the encrypted store and
|
||||||
|
* remove the plain copy. Preserves the existing key so already-scanned QR
|
||||||
|
* clients keep working — generating a fresh key here would silently 401
|
||||||
|
* every LAN client (see the Data Migration Policy in CLAUDE.md).
|
||||||
|
*/
|
||||||
|
private fun migrateLegacyKeyIfPresent() {
|
||||||
|
// Don't migrate if the encrypted store already holds a key.
|
||||||
|
if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return
|
||||||
|
runCatching {
|
||||||
|
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val legacyKey = legacy.getString(KEY_API_KEY, null)
|
||||||
|
if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) {
|
||||||
|
// commit() returns false on write failure WITHOUT throwing, so the
|
||||||
|
// runCatching wrapper alone does NOT protect this path. Verify the
|
||||||
|
// encrypted store both committed AND reads back the identical value
|
||||||
|
// before touching the legacy copy — otherwise a silent write
|
||||||
|
// failure could delete the only surviving copy of the key and
|
||||||
|
// rotate it on next launch (401s every paired client — the exact
|
||||||
|
// silent-data-loss the Data Migration Policy forbids).
|
||||||
|
val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit()
|
||||||
|
if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) {
|
||||||
|
// Keep the value as a .migrated backup (don't hard-delete) per
|
||||||
|
// the migration policy; remove only the live legacy key so the
|
||||||
|
// plaintext copy no longer answers reads.
|
||||||
|
legacy.edit()
|
||||||
|
.putString(KEY_API_KEY_MIGRATED, legacyKey)
|
||||||
|
.remove(KEY_API_KEY)
|
||||||
|
.apply()
|
||||||
|
Log.i(TAG, "Migrated API key from plain to encrypted storage")
|
||||||
|
} else {
|
||||||
|
// Leave the legacy key untouched; getOrCreateKey() will recover
|
||||||
|
// it via recoverLegacyKey() rather than minting a fresh one.
|
||||||
|
Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover a still-present key from the legacy plain store — either the live
|
||||||
|
* key (failed/never-run migration) or the `.migrated` backup. Returns null
|
||||||
|
* when on the plain-prefs path (no legacy/encrypted split) or no valid key
|
||||||
|
* survives. Guarantees [getOrCreateKey] never rotates an existing key as long
|
||||||
|
* as the legacy file survives.
|
||||||
|
*/
|
||||||
|
private fun recoverLegacyKey(): String? {
|
||||||
|
if (!encrypted) return null
|
||||||
|
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val candidate = legacy.getString(KEY_API_KEY, null)
|
||||||
|
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
|
||||||
|
return candidate?.takeIf { it.length >= MIN_KEY_LENGTH }
|
||||||
|
}
|
||||||
|
|
||||||
private fun generateKey(): String {
|
private fun generateKey(): String {
|
||||||
val bytes = ByteArray(KEY_BYTES)
|
val bytes = ByteArray(KEY_BYTES)
|
||||||
SecureRandom().nextBytes(bytes)
|
SecureRandom().nextBytes(bytes)
|
||||||
@@ -88,7 +202,11 @@ class ApiKeyManager(context: Context) {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ApiKeyManager"
|
private const val TAG = "ApiKeyManager"
|
||||||
private const val PREFS_NAME = "ledgrab_auth"
|
private const val PREFS_NAME = "ledgrab_auth"
|
||||||
|
private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
|
||||||
private const val KEY_API_KEY = "api_key"
|
private const val KEY_API_KEY = "api_key"
|
||||||
|
// Backup of a migrated legacy key, kept in the plain store per the
|
||||||
|
// Data Migration Policy (never hard-delete user data on rename/move).
|
||||||
|
private const val KEY_API_KEY_MIGRATED = "api_key_migrated"
|
||||||
private const val KEY_BYTES = 32
|
private const val KEY_BYTES = 32
|
||||||
private const val MIN_KEY_LENGTH = 32
|
private const val MIN_KEY_LENGTH = 32
|
||||||
|
|
||||||
|
|||||||
@@ -103,12 +103,32 @@ object BleBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bleHandler.post { scanner.startScan(callback) }
|
// startScan runs on the BLE handler thread; a denied
|
||||||
|
// BLUETOOTH_SCAN throws SecurityException there, which would
|
||||||
|
// crash the whole process (an uncaught exception on a handler
|
||||||
|
// thread is fatal). Catch it inside the posted body and report.
|
||||||
|
bleHandler.post {
|
||||||
|
try {
|
||||||
|
scanner.startScan(callback)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "BLE startScan failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
Thread.sleep(timeoutMs)
|
Thread.sleep(timeoutMs)
|
||||||
} catch (_: InterruptedException) {
|
} catch (_: InterruptedException) {
|
||||||
Thread.currentThread().interrupt()
|
Thread.currentThread().interrupt()
|
||||||
} finally {
|
} finally {
|
||||||
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
|
bleHandler.post {
|
||||||
|
try {
|
||||||
|
scanner.stopScan(callback)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "BLE stopScan failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return seen.values.toList()
|
return seen.values.toList()
|
||||||
}
|
}
|
||||||
@@ -136,7 +156,18 @@ object BleBridge {
|
|||||||
newState == BluetoothProfile.STATE_CONNECTED
|
newState == BluetoothProfile.STATE_CONNECTED
|
||||||
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
||||||
Log.d(TAG, "GATT connected to $address, discovering services")
|
Log.d(TAG, "GATT connected to $address, discovering services")
|
||||||
gatt.discoverServices()
|
// Runs on the BLE handler thread; a denied
|
||||||
|
// BLUETOOTH_CONNECT throws SecurityException here, which
|
||||||
|
// would crash the process. Catch and fail the connect.
|
||||||
|
try {
|
||||||
|
gatt.discoverServices()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e)
|
||||||
|
readyDeferred.complete(false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "discoverServices failed: ${e.message}")
|
||||||
|
readyDeferred.complete(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
||||||
|
|||||||
@@ -105,12 +105,48 @@ class CaptureService : Service() {
|
|||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||||
|
|
||||||
// CRITICAL: startForeground must be called IMMEDIATELY — before
|
// CRITICAL (Android 14+): for the MediaProjection path, validate the
|
||||||
// any other work, especially before getMediaProjection(). The
|
// projection token BEFORE promoting to a foreground service with the
|
||||||
// service type must match the work; pass it explicitly via
|
// mediaProjection FGS type. On service recreation (system redelivery
|
||||||
// ServiceCompat so we stay compatible back to API 24.
|
// or a stale relaunch) the consent token is gone — promoting first and
|
||||||
|
// then discovering the dead token causes a spurious foreground-service
|
||||||
|
// start + immediate stop, which on strict OEMs flickers the
|
||||||
|
// notification or trips a stopSelf loop. Bail out cleanly here, before
|
||||||
|
// startForeground, when the MediaProjection consent data is missing.
|
||||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
||||||
val url = "http://$localIp:$SERVER_PORT"
|
val url = "http://$localIp:$SERVER_PORT"
|
||||||
|
|
||||||
|
val mediaProjectionResultData: Intent? =
|
||||||
|
if (!useRoot) extractProjectionResultData(intent) else null
|
||||||
|
if (!useRoot && (intent == null || mediaProjectionResultData == null)) {
|
||||||
|
// MediaProjection mode can't recover from a redelivery —
|
||||||
|
// the consent token in the original intent is single-use.
|
||||||
|
//
|
||||||
|
// We were launched via startForegroundService(), so the OS REQUIRES
|
||||||
|
// a startForeground() within ~5s even on this immediate-stop path,
|
||||||
|
// or it raises the fatal ForegroundServiceDidNotStartInTimeException.
|
||||||
|
// Promote with a benign SPECIAL_USE type (NOT mediaProjection — we
|
||||||
|
// have no valid consent token, and requesting that type without an
|
||||||
|
// active projection is exactly what we're avoiding) just long enough
|
||||||
|
// to satisfy the contract, then stop.
|
||||||
|
Log.w(TAG, "MediaProjection start without a valid consent token — stopping")
|
||||||
|
runCatching {
|
||||||
|
val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType)
|
||||||
|
}.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") }
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
// startForeground must be called IMMEDIATELY after the token check —
|
||||||
|
// before any heavier work like getMediaProjection(). The service type
|
||||||
|
// must match the work; pass it explicitly via ServiceCompat so we stay
|
||||||
|
// compatible back to API 24. The MEDIA_PROJECTION type is only used
|
||||||
|
// here once resultData is confirmed non-null (checked above).
|
||||||
try {
|
try {
|
||||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
var t = if (useRoot) {
|
var t = if (useRoot) {
|
||||||
@@ -152,20 +188,13 @@ class CaptureService : Service() {
|
|||||||
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
if (intent == null && !useRoot) {
|
|
||||||
// MediaProjection mode can't recover from a redelivery —
|
|
||||||
// the consent token in the original intent is single-use.
|
|
||||||
Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
|
|
||||||
isRunning = false
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (useRoot) {
|
if (useRoot) {
|
||||||
startRootCapture(url)
|
startRootCapture(url)
|
||||||
} else {
|
} else {
|
||||||
startMediaProjectionCapture(intent!!, url)
|
// mediaProjectionResultData is guaranteed non-null here — the
|
||||||
|
// token was validated before startForeground above.
|
||||||
|
startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start capture", e)
|
Log.e(TAG, "Failed to start capture", e)
|
||||||
@@ -294,20 +323,24 @@ class CaptureService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startMediaProjectionCapture(intent: Intent, url: String) {
|
/**
|
||||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
* Extract the single-use MediaProjection consent token from the start
|
||||||
|
* intent, or null if the intent is missing/redelivered without it.
|
||||||
|
* Called BEFORE startForeground so the mediaProjection FGS type is only
|
||||||
|
* ever requested when a valid token is present (see onStartCommand).
|
||||||
|
*/
|
||||||
|
private fun extractProjectionResultData(intent: Intent?): Intent? {
|
||||||
|
if (intent == null) return null
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||||
} else {
|
} else {
|
||||||
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (resultData == null) {
|
private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) {
|
||||||
Log.e(TAG, "No MediaProjection result data")
|
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||||
stopSelf()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val projectionManager =
|
val projectionManager =
|
||||||
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import android.service.notification.StatusBarNotification
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.chaquo.python.Python
|
import com.chaquo.python.Python
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.RejectedExecutionException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures posted OS notifications and forwards the posting app's display
|
* Captures posted OS notifications and forwards the posting app's display
|
||||||
@@ -25,7 +27,20 @@ class LedGrabNotificationListener : NotificationListenerService() {
|
|||||||
// Serial executor: the Python receiver does a (non-concurrency-safe) history
|
// Serial executor: the Python receiver does a (non-concurrency-safe) history
|
||||||
// disk write and may play a sound, so pushes must not overlap. Off the main
|
// disk write and may play a sound, so pushes must not overlap. Off the main
|
||||||
// looper to keep the system service responsive.
|
// looper to keep the system service responsive.
|
||||||
private val pushExecutor = Executors.newSingleThreadExecutor()
|
//
|
||||||
|
// Tied to the listener-connection lifecycle (onListenerConnected /
|
||||||
|
// onListenerDisconnected), NOT onDestroy: this is a system-rebindable
|
||||||
|
// service, so it can be connected/disconnected multiple times across a
|
||||||
|
// single onCreate..onDestroy span. Managing the executor here — combined
|
||||||
|
// with the runCatching guard at the submit site — keeps a notification
|
||||||
|
// that races teardown from triggering RejectedExecutionException on a
|
||||||
|
// shut-down executor. @Volatile so the connect/disconnect callbacks (which
|
||||||
|
// may run on a different thread than onNotificationPosted) publish safely.
|
||||||
|
@Volatile private var pushExecutor: ExecutorService? = null
|
||||||
|
|
||||||
|
// Guards executor creation so the lazy submit-site fallback and
|
||||||
|
// onListenerConnected can't race two executors into existence.
|
||||||
|
private val executorLock = Any()
|
||||||
|
|
||||||
// packageName -> resolved human-readable label. Matches the app_name the
|
// packageName -> resolved human-readable label. Matches the app_name the
|
||||||
// Windows/Linux backends pass, so per-app colors/filters keep working.
|
// Windows/Linux backends pass, so per-app colors/filters keep working.
|
||||||
@@ -51,17 +66,34 @@ class LedGrabNotificationListener : NotificationListenerService() {
|
|||||||
|
|
||||||
val label = resolveAppLabel(notification.packageName)
|
val label = resolveAppLabel(notification.packageName)
|
||||||
|
|
||||||
pushExecutor.execute {
|
// Obtain (creating if needed) the executor. onListenerConnected normally
|
||||||
try {
|
// creates it, but that callback is not reliably invoked on every
|
||||||
Python.getInstance()
|
// OEM/version (re)bind, and a notification can arrive before it fires —
|
||||||
.getModule(PY_MODULE)
|
// lazily creating here keeps a missing/late onListenerConnected from
|
||||||
.callAttr("push_notification", label)
|
// permanently disabling notification forwarding. A late submit onto an
|
||||||
} catch (t: Throwable) {
|
// executor that onListenerDisconnected is shutting down throws
|
||||||
// Never crash a system-bound service. Python.getInstance() throws
|
// RejectedExecutionException — guard with runCatching so a notification
|
||||||
// IllegalStateException if Python.start() hasn't run (e.g. the
|
// racing teardown can never crash this system-bound service.
|
||||||
// service was bound at boot before the app process initialized).
|
val executor = ensureExecutor()
|
||||||
// Log at debug — the label is potentially sensitive on a shared TV.
|
runCatching {
|
||||||
Log.d(TAG, "push_notification failed: ${t.message}")
|
executor.execute {
|
||||||
|
try {
|
||||||
|
Python.getInstance()
|
||||||
|
.getModule(PY_MODULE)
|
||||||
|
.callAttr("push_notification", label)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// Never crash a system-bound service. Python.getInstance() throws
|
||||||
|
// IllegalStateException if Python.start() hasn't run (e.g. the
|
||||||
|
// service was bound at boot before the app process initialized).
|
||||||
|
// Log at debug — the label is potentially sensitive on a shared TV.
|
||||||
|
Log.d(TAG, "push_notification failed: ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
if (e is RejectedExecutionException) {
|
||||||
|
Log.d(TAG, "push rejected — listener disconnecting")
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,24 +101,59 @@ class LedGrabNotificationListener : NotificationListenerService() {
|
|||||||
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
|
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
|
||||||
private fun resolveAppLabel(pkg: String): String {
|
private fun resolveAppLabel(pkg: String): String {
|
||||||
labelCache[pkg]?.let { return it }
|
labelCache[pkg]?.let { return it }
|
||||||
|
// Only cache SUCCESSFUL resolutions. Caching the package-name fallback
|
||||||
|
// would permanently pin a wrong label if the PackageManager lookup
|
||||||
|
// failed transiently (e.g. the app was mid-install / still updating).
|
||||||
val resolved = runCatching {
|
val resolved = runCatching {
|
||||||
val info = packageManager.getApplicationInfo(pkg, 0)
|
val info = packageManager.getApplicationInfo(pkg, 0)
|
||||||
packageManager.getApplicationLabel(info).toString()
|
packageManager.getApplicationLabel(info).toString()
|
||||||
}.getOrDefault(pkg)
|
}.getOrNull()
|
||||||
labelCache[pkg] = resolved
|
if (resolved != null) {
|
||||||
return resolved
|
labelCache[pkg] = resolved
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the push executor, creating it under [executorLock] if absent.
|
||||||
|
* Safe against a concurrent onListenerConnected/onNotificationPosted race
|
||||||
|
* (single executor) and against a missing onListenerConnected callback.
|
||||||
|
*/
|
||||||
|
private fun ensureExecutor(): ExecutorService {
|
||||||
|
pushExecutor?.let { return it }
|
||||||
|
synchronized(executorLock) {
|
||||||
|
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onListenerConnected() {
|
override fun onListenerConnected() {
|
||||||
Log.i(TAG, "Notification listener connected")
|
Log.i(TAG, "Notification listener connected")
|
||||||
|
// Spin up the push executor on connect. The system can disconnect and
|
||||||
|
// later reconnect this service without destroying it, so own the
|
||||||
|
// executor here rather than in onCreate/onDestroy. onNotificationPosted
|
||||||
|
// also lazily creates it (via ensureExecutor) in case this callback is
|
||||||
|
// late or skipped on some ROMs.
|
||||||
|
ensureExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onListenerDisconnected() {
|
override fun onListenerDisconnected() {
|
||||||
Log.i(TAG, "Notification listener disconnected")
|
Log.i(TAG, "Notification listener disconnected")
|
||||||
|
// Tear the executor down on disconnect; a fresh one is created on the
|
||||||
|
// next onListenerConnected. Null out first so any in-flight
|
||||||
|
// onNotificationPosted snapshots see null (skips submit) rather than
|
||||||
|
// racing a shutdown executor.
|
||||||
|
pushExecutor?.let { exec ->
|
||||||
|
pushExecutor = null
|
||||||
|
exec.shutdown()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
pushExecutor.shutdown()
|
// Defensive: onListenerDisconnected normally clears this first, but
|
||||||
|
// shut down here too in case onDestroy fires without a prior disconnect.
|
||||||
|
pushExecutor?.shutdown()
|
||||||
|
pushExecutor = null
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,7 @@ class MainActivity : Activity() {
|
|||||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||||
private const val REQUEST_RECORD_AUDIO = 1003
|
private const val REQUEST_RECORD_AUDIO = 1003
|
||||||
private const val REQUEST_CAMERA = 1004
|
private const val REQUEST_CAMERA = 1004
|
||||||
|
private const val REQUEST_BLUETOOTH = 1005
|
||||||
private const val QR_SIZE_PX = 560
|
private const val QR_SIZE_PX = 560
|
||||||
private const val NOTIF_PREFS = "ledgrab_notif"
|
private const val NOTIF_PREFS = "ledgrab_notif"
|
||||||
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
|
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
|
||||||
@@ -189,7 +191,13 @@ class MainActivity : Activity() {
|
|||||||
toggleButton.text = getString(R.string.btn_starting)
|
toggleButton.text = getString(R.string.btn_starting)
|
||||||
statusText.text = getString(R.string.status_checking_root)
|
statusText.text = getString(R.string.status_checking_root)
|
||||||
uiScope.launch(Dispatchers.IO) {
|
uiScope.launch(Dispatchers.IO) {
|
||||||
val rooted = Root.requestGrant()
|
// runInterruptible so a config change (rotation) during the
|
||||||
|
// up-to-10s `su` probe cancels the coroutine AND interrupts the
|
||||||
|
// blocking probe thread — Root.requestGrant honours the interrupt,
|
||||||
|
// destroys the su child, and rethrows, so we don't leak the
|
||||||
|
// process + drain thread. Without this, IO-dispatcher cancellation
|
||||||
|
// would not interrupt the blocking waitFor().
|
||||||
|
val rooted = runInterruptible { Root.requestGrant() }
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
toggleButton.isEnabled = true
|
toggleButton.isEnabled = true
|
||||||
toggleButton.text = originalText
|
toggleButton.text = originalText
|
||||||
@@ -214,6 +222,7 @@ class MainActivity : Activity() {
|
|||||||
ensureNotificationPermission()
|
ensureNotificationPermission()
|
||||||
ensureNotificationListenerAccess()
|
ensureNotificationListenerAccess()
|
||||||
ensureCameraPermission()
|
ensureCameraPermission()
|
||||||
|
ensureBluetoothPermissions()
|
||||||
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
||||||
updateUI()
|
updateUI()
|
||||||
}
|
}
|
||||||
@@ -236,6 +245,7 @@ class MainActivity : Activity() {
|
|||||||
ensureNotificationListenerAccess()
|
ensureNotificationListenerAccess()
|
||||||
ensureAudioPermission()
|
ensureAudioPermission()
|
||||||
ensureCameraPermission()
|
ensureCameraPermission()
|
||||||
|
ensureBluetoothPermissions()
|
||||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||||
ContextCompat.startForegroundService(this, intent)
|
ContextCompat.startForegroundService(this, intent)
|
||||||
updateUI()
|
updateUI()
|
||||||
@@ -536,6 +546,30 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded
|
||||||
|
* server can discover and drive BLE LED controllers (SP110E / Triones /
|
||||||
|
* Zengge). On API < 31 these are install-time legacy permissions
|
||||||
|
* (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and
|
||||||
|
* need no runtime grant — so this is a no-op there. Fire-and-forget,
|
||||||
|
* like [ensureAudioPermission]: screen capture works without BLE, and
|
||||||
|
* BleBridge degrades gracefully (empty scan / failed connect) when the
|
||||||
|
* grant is denied, so we don't block on the result. If first granted
|
||||||
|
* here, BLE devices become reachable on the next scan/connect.
|
||||||
|
*/
|
||||||
|
private fun ensureBluetoothPermissions() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||||
|
val needed = listOf(
|
||||||
|
Manifest.permission.BLUETOOTH_SCAN,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
).filter {
|
||||||
|
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (needed.isEmpty()) return
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH)
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether the user has granted notification-listener access to this app. */
|
/** Whether the user has granted notification-listener access to this app. */
|
||||||
private fun isNotificationAccessGranted(): Boolean =
|
private fun isNotificationAccessGranted(): Boolean =
|
||||||
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
|
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit
|
|||||||
object Root {
|
object Root {
|
||||||
private const val TAG = "Root"
|
private const val TAG = "Root"
|
||||||
|
|
||||||
|
// Slice length for the cancellation-aware su probe wait loop. Short
|
||||||
|
// enough that coroutine cancellation is honoured promptly, long enough
|
||||||
|
// to avoid busy-spinning while Magisk's grant dialog is up.
|
||||||
|
private const val POLL_SLICE_MS = 100L
|
||||||
|
|
||||||
private val SU_PATHS = listOf(
|
private val SU_PATHS = listOf(
|
||||||
"/system/bin/su",
|
"/system/bin/su",
|
||||||
"/system/xbin/su",
|
"/system/xbin/su",
|
||||||
@@ -49,17 +54,19 @@ object Root {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var process: Process? = null
|
||||||
val granted = try {
|
val granted = try {
|
||||||
// redirectErrorStream merges stderr into stdout so a single
|
// redirectErrorStream merges stderr into stdout so a single
|
||||||
// drain thread is enough — avoids the classic pipe-buffer
|
// drain thread is enough — avoids the classic pipe-buffer
|
||||||
// deadlock where waitFor() blocks because stderr filled up.
|
// deadlock where waitFor() blocks because stderr filled up.
|
||||||
val process = ProcessBuilder("su", "-c", "id")
|
val proc = ProcessBuilder("su", "-c", "id")
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start()
|
.start()
|
||||||
|
process = proc
|
||||||
val outputBuilder = StringBuilder()
|
val outputBuilder = StringBuilder()
|
||||||
val drain = Thread({
|
val drain = Thread({
|
||||||
try {
|
try {
|
||||||
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
|
BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
|
||||||
val buf = CharArray(512)
|
val buf = CharArray(512)
|
||||||
while (true) {
|
while (true) {
|
||||||
val n = r.read(buf)
|
val n = r.read(buf)
|
||||||
@@ -72,17 +79,35 @@ object Root {
|
|||||||
}
|
}
|
||||||
}, "Root-su-drain").apply { isDaemon = true; start() }
|
}, "Root-su-drain").apply { isDaemon = true; start() }
|
||||||
|
|
||||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
// Cancellation-aware wait: callers run this on a coroutine
|
||||||
|
// (MainActivity wraps it in runInterruptible), so a config change
|
||||||
|
// mid-probe cancels the coroutine and interrupts this thread.
|
||||||
|
// Poll waitFor() in short slices and honour interruption so we
|
||||||
|
// don't leak the `su` child + its drain thread for up to 10s.
|
||||||
|
// The catch(InterruptedException) below destroys the process; we
|
||||||
|
// re-arm the interrupt and rethrow so coroutine cancellation
|
||||||
|
// propagates cleanly.
|
||||||
|
val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
|
||||||
|
var finished = false
|
||||||
|
while (System.nanoTime() < deadlineNanos) {
|
||||||
|
if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) {
|
||||||
|
finished = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Throws InterruptedException if the thread was interrupted
|
||||||
|
// by coroutine cancellation — handled below to tear down.
|
||||||
|
if (Thread.interrupted()) throw InterruptedException("su probe cancelled")
|
||||||
|
}
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
process.destroyForcibly()
|
proc.destroyForcibly()
|
||||||
drain.join(500)
|
drain.join(500)
|
||||||
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
drain.join(500)
|
drain.join(500)
|
||||||
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
||||||
if (process.exitValue() != 0) {
|
if (proc.exitValue() != 0) {
|
||||||
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
|
Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'")
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
val rooted = output.contains("uid=0")
|
val rooted = output.contains("uid=0")
|
||||||
@@ -90,8 +115,17 @@ object Root {
|
|||||||
rooted
|
rooted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
// Coroutine cancelled mid-probe (e.g. config change). Kill the
|
||||||
|
// su child so it doesn't outlive the cancelled work, re-arm the
|
||||||
|
// interrupt flag, and rethrow so the coroutine cancels cleanly.
|
||||||
|
// Do NOT cache a result — the probe never completed.
|
||||||
|
runCatching { process?.destroyForcibly() }
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "su invocation failed: ${e.message}")
|
Log.w(TAG, "su invocation failed: ${e.message}")
|
||||||
|
runCatching { process?.destroyForcibly() }
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,14 +89,15 @@ class RootScreenrecord(
|
|||||||
running = true
|
running = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
imageReader = buildImageReader()
|
val reader = buildImageReader().also { imageReader = it }
|
||||||
decoder = buildDecoder(imageReader!!)
|
val codec = buildDecoder(reader).also { decoder = it }
|
||||||
process = spawnScreenrecord() ?: run {
|
val proc = spawnScreenrecord() ?: run {
|
||||||
stop()
|
stop()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
startInputPump(process!!.inputStream, decoder!!)
|
process = proc
|
||||||
startOutputDrain(decoder!!)
|
startInputPump(proc.inputStream, codec)
|
||||||
|
startOutputDrain(codec)
|
||||||
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -178,6 +179,14 @@ class RootScreenrecord(
|
|||||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||||
|
// reader callback — no copy here). Safety depends on the Python
|
||||||
|
// receiver copying the bytes before this callback returns and
|
||||||
|
// overwrites the buffer for the next frame. It does:
|
||||||
|
// PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame
|
||||||
|
// (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py)
|
||||||
|
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||||
|
// pixels independently of this buffer. Do NOT remove that copy.
|
||||||
bridge.pushRootFrame(frameBuffer, width, height)
|
bridge.pushRootFrame(frameBuffer, width, height)
|
||||||
framesDeliveredCounter.incrementAndGet()
|
framesDeliveredCounter.incrementAndGet()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -147,6 +147,14 @@ class ScreenCapture(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||||
|
// capture handler — no copy here). Safety depends on the Python
|
||||||
|
// receiver copying the bytes before this callback returns and
|
||||||
|
// overwrites the buffer for the next frame. It does:
|
||||||
|
// PythonBridge.pushFrame → mediaprojection_engine.push_frame
|
||||||
|
// (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py)
|
||||||
|
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||||
|
// pixels independently of this buffer. Do NOT remove that copy.
|
||||||
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
||||||
|
|
||||||
// Advance the pacing accumulator. If we fell badly behind
|
// Advance the pacing accumulator. If we fell badly behind
|
||||||
|
|||||||
+156
-2
@@ -42,6 +42,7 @@ Complete REST + WebSocket API reference for the LedGrab server.
|
|||||||
- [Weather sources](#weather-sources)
|
- [Weather sources](#weather-sources)
|
||||||
- [Automations](#automations)
|
- [Automations](#automations)
|
||||||
- [Scene presets](#scene-presets)
|
- [Scene presets](#scene-presets)
|
||||||
|
- [Scene playlists](#scene-playlists)
|
||||||
- [Sync clocks](#sync-clocks)
|
- [Sync clocks](#sync-clocks)
|
||||||
- [Webhooks](#webhooks)
|
- [Webhooks](#webhooks)
|
||||||
- [HTTP endpoints](#http-endpoints)
|
- [HTTP endpoints](#http-endpoints)
|
||||||
@@ -184,7 +185,7 @@ Server configuration: MQTT broker, external URL, shutdown action, log level, ADB
|
|||||||
|
|
||||||
## User preferences
|
## User preferences
|
||||||
|
|
||||||
Dashboard layout, notification settings, card display modes, and the global daylight timezone.
|
Dashboard layout, notification settings, card display modes, the global daylight timezone, and the first-run onboarding flag.
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ---- | ----------- |
|
| ------ | ---- | ----------- |
|
||||||
@@ -198,6 +199,19 @@ Dashboard layout, notification settings, card display modes, and the global dayl
|
|||||||
| DELETE | `/api/v1/preferences/card-modes` | Delete card-mode preferences; revert to defaults. |
|
| DELETE | `/api/v1/preferences/card-modes` | Delete card-mode preferences; revert to defaults. |
|
||||||
| GET | `/api/v1/preferences/daylight-timezone` | Read the global IANA timezone for daylight cycles. |
|
| GET | `/api/v1/preferences/daylight-timezone` | Read the global IANA timezone for daylight cycles. |
|
||||||
| PUT | `/api/v1/preferences/daylight-timezone` | Persist the daylight-cycle timezone (empty = server local). |
|
| PUT | `/api/v1/preferences/daylight-timezone` | Persist the daylight-cycle timezone (empty = server local). |
|
||||||
|
| GET | `/api/v1/preferences/onboarding` | Read the first-run onboarding flag (`onboarded: bool`, `completed_at: str\|null`). Defaults to `false`. |
|
||||||
|
| PUT | `/api/v1/preferences/onboarding` | Persist the onboarding flag. Server auto-stamps `completed_at` when `onboarded` is set to `true` without a timestamp. |
|
||||||
|
|
||||||
|
**Onboarding flag response shape:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"onboarded": true,
|
||||||
|
"completed_at": "2026-06-08T12:00:00.000000+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults to `{"onboarded": false, "completed_at": null}` when never set.
|
||||||
|
|
||||||
## Backup, restore & server control
|
## Backup, restore & server control
|
||||||
|
|
||||||
@@ -237,7 +251,7 @@ A single aggregated poll endpoint for low-overhead clients.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ---- | ----------- |
|
| ------ | ---- | ----------- |
|
||||||
| GET | `/api/v1/snapshot` | Full poll payload (targets, states, metrics, devices, brightness, color/value sources, scene presets, sync clocks, system) in one response. Use `?include=` to request a subset; per-section fault isolation. |
|
| GET | `/api/v1/snapshot` | Full poll payload (targets, states, metrics, devices, brightness, color/value sources, scene presets, scene playlists + cycling state, sync clocks, system) in one response. Use `?include=` to request a subset; per-section fault isolation. |
|
||||||
|
|
||||||
## Devices
|
## Devices
|
||||||
|
|
||||||
@@ -518,6 +532,25 @@ Captured snapshots of target state that can be restored.
|
|||||||
| POST | `/api/v1/scene-presets/{preset_id}/recapture` | Re-capture current state into the preset. |
|
| POST | `/api/v1/scene-presets/{preset_id}/recapture` | Re-capture current state into the preset. |
|
||||||
| POST | `/api/v1/scene-presets/{preset_id}/activate` | Activate the preset (restore captured state). |
|
| POST | `/api/v1/scene-presets/{preset_id}/activate` | Activate the preset (restore captured state). |
|
||||||
|
|
||||||
|
## Scene playlists
|
||||||
|
|
||||||
|
Ordered, timed sequences of scene presets that auto-cycle. The engine drives
|
||||||
|
**one** playlist at a time — starting a playlist stops any other. Each item
|
||||||
|
references a scene preset and holds it for its `duration_seconds` (min 1s)
|
||||||
|
before advancing; `loop` repeats from the start and `shuffle` randomises the
|
||||||
|
order each cycle.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---- | ----------- |
|
||||||
|
| POST | `/api/v1/scene-playlists` | Create a playlist (items reference scene presets). |
|
||||||
|
| GET | `/api/v1/scene-playlists` | List all playlists plus the current cycling `state`. |
|
||||||
|
| GET | `/api/v1/scene-playlists/state` | Get the current cycling state (idle if nothing runs). |
|
||||||
|
| GET | `/api/v1/scene-playlists/{playlist_id}` | Get a playlist by ID. |
|
||||||
|
| PUT | `/api/v1/scene-playlists/{playlist_id}` | Update metadata, items, and `loop`/`shuffle`. |
|
||||||
|
| DELETE | `/api/v1/scene-playlists/{playlist_id}` | Delete a playlist (stops it first if running). |
|
||||||
|
| POST | `/api/v1/scene-playlists/{playlist_id}/start` | Start cycling (stops any other playlist first). |
|
||||||
|
| POST | `/api/v1/scene-playlists/stop` | Stop the active playlist (leaves the last scene applied). |
|
||||||
|
|
||||||
## Sync clocks
|
## Sync clocks
|
||||||
|
|
||||||
Shared clocks that drive linked animations with configurable speed.
|
Shared clocks that drive linked animations with configurable speed.
|
||||||
@@ -629,6 +662,127 @@ The wiring-graph: schema registry, topology, dependents, validation, and subgrap
|
|||||||
| POST | `/api/v1/graph/validate-connection` | Validate a proposed wiring edit (existence, kind, no cycle). |
|
| POST | `/api/v1/graph/validate-connection` | Validate a proposed wiring edit (existence, kind, no cycle). |
|
||||||
| POST | `/api/v1/graph/duplicate` | Deep-clone selected value/color-strip sources with remapped wiring. |
|
| POST | `/api/v1/graph/duplicate` | Deep-clone selected value/color-strip sources with remapped wiring. |
|
||||||
|
|
||||||
|
## Calibration
|
||||||
|
|
||||||
|
Guided LED chase and auto-solver for the `CalibrationConfig` stored on a
|
||||||
|
color-strip source. The flow is:
|
||||||
|
|
||||||
|
1. **Start** a session (`POST /session`) — stops any running target on the
|
||||||
|
device and remembers it for restore on stop.
|
||||||
|
2. **Position** the chase pixel (`POST /session/position`) to walk through
|
||||||
|
each physical corner and record the LED index.
|
||||||
|
3. **Solve** (`POST /solve`) — the server computes per-edge LED counts.
|
||||||
|
4. **Persist** — call `PUT /api/v1/color-strip-sources/{id}` with the solved
|
||||||
|
`calibration` object to save and hot-reload.
|
||||||
|
5. **Stop** (`POST /session/stop`) — clears the device and restores the prior
|
||||||
|
target.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---- | ----------- |
|
||||||
|
| POST | `/api/v1/calibration/session` | Start a calibration session on a device (stops the running target, clears to black). |
|
||||||
|
| POST | `/api/v1/calibration/session/position` | Advance the chase pixel to LED `index` (± `window` dim neighbours). |
|
||||||
|
| POST | `/api/v1/calibration/session/stop` | End the session: clear to black and restore the prior target. |
|
||||||
|
| POST | `/api/v1/calibration/session/cancel` | Alias for stop — no calibration is applied. |
|
||||||
|
| GET | `/api/v1/calibration/session/state` | Current session state (active, device_id, led_count, last_activity). |
|
||||||
|
| POST | `/api/v1/calibration/solve` | Solve per-edge LED counts from 4 corner tap indices. Returns solved config dict (does NOT persist). |
|
||||||
|
|
||||||
|
**Session state** response shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"device_id": "dev_abc123",
|
||||||
|
"led_count": 100,
|
||||||
|
"prior_target_id": "ot_xyz456",
|
||||||
|
"last_activity": "2026-06-08T12:34:56.789Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solve request** (body):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "dev_abc123",
|
||||||
|
"start_position": "bottom_left",
|
||||||
|
"layout": "clockwise",
|
||||||
|
"corner_indices": [0, 30, 60, 80],
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`corner_indices` must be exactly 4 integers, one per screen corner, in the
|
||||||
|
strip-walk order defined by `(start_position, layout)`. Provide either
|
||||||
|
`device_id` (preferred — server derives `led_count`) or `led_count` directly.
|
||||||
|
|
||||||
|
**Important session behavior:**
|
||||||
|
|
||||||
|
- **Stops the running output target** — starting a calibration session immediately
|
||||||
|
stops any output target currently running on that device. Other clients driving
|
||||||
|
that device will lose their output for the duration of the session.
|
||||||
|
- **Single session only** — only one calibration session runs at a time across the
|
||||||
|
whole server. Starting a new session automatically ends the previous one (clearing
|
||||||
|
and restoring its device first), regardless of which device each session is on.
|
||||||
|
- **Idle auto-end** — a session that receives no `position` calls for ~60 seconds is
|
||||||
|
automatically stopped and the prior target restored, so devices are never left dark
|
||||||
|
indefinitely.
|
||||||
|
|
||||||
|
**Idle timeout:** a session that receives no `position` calls for 60 seconds
|
||||||
|
is automatically stopped and the prior target restored.
|
||||||
|
|
||||||
|
## Setup scaffold
|
||||||
|
|
||||||
|
One-call first-run helper that creates the full capture-to-output chain and
|
||||||
|
returns all entity ids. The wizard calls this, then starts the output target
|
||||||
|
after optional calibration.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---- | ----------- |
|
||||||
|
| POST | `/api/v1/setup/scaffold` | Create capture template + picture source + color-strip source + LED output target in one atomic call with rollback on partial failure. Does NOT auto-start the target. |
|
||||||
|
|
||||||
|
**Wizard sequence (Phase 4):**
|
||||||
|
|
||||||
|
1. Discover or create the device via `POST /api/v1/devices` (full URL
|
||||||
|
normalisation + provider validation runs there).
|
||||||
|
2. Call `POST /api/v1/setup/scaffold` with the resulting `device_id`.
|
||||||
|
3. Calibrate (Phase 1 endpoints).
|
||||||
|
4. Start the output target via `POST /api/v1/output-targets/{id}/start`.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "device_abc123",
|
||||||
|
"display_index": 0,
|
||||||
|
"calibration": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`device_id` is **required** and must reference an existing device (created via
|
||||||
|
`POST /api/v1/devices`). `display_index` selects the monitor to capture
|
||||||
|
(0 = primary; range 0–63). `calibration` is an optional `CalibrationConfig`
|
||||||
|
dict; when omitted, `create_default_calibration(led_count)` is used.
|
||||||
|
|
||||||
|
**Response (201 Created):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "device_abc123",
|
||||||
|
"capture_template_id": "tpl_11223344",
|
||||||
|
"picture_source_id": "ps_aabbccdd",
|
||||||
|
"color_strip_source_id": "css_11223344",
|
||||||
|
"output_target_id": "pt_aabbccdd",
|
||||||
|
"capture_template_reused": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`capture_template_reused` is `true` when an existing template matched the
|
||||||
|
platform engine (no new template was created).
|
||||||
|
|
||||||
|
**Rollback:** if any step fails, all entities created within the same call are
|
||||||
|
deleted in reverse order so no orphans remain. The pre-existing device and any
|
||||||
|
reused template are never deleted. Entity "created" events are emitted only
|
||||||
|
after the full chain succeeds, so a rollback never produces ghost UI cards.
|
||||||
|
|
||||||
## Web UI & PWA
|
## Web UI & PWA
|
||||||
|
|
||||||
App-level routes served by FastAPI (not under `/api/v1`).
|
App-level routes served by FastAPI (not under `/api/v1`).
|
||||||
|
|||||||
+42
-1
@@ -54,7 +54,48 @@ When you attach a device, a default calibration is created:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Custom Calibration
|
## Automatic Calibration
|
||||||
|
|
||||||
|
The easiest way to calibrate your strip is the **Auto-Calibrate** wizard, available directly
|
||||||
|
from the calibration modal. No LED counting required — just answer three questions and tap four
|
||||||
|
corners.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- A **Color Strip Source** (not a device-only target) associated with the strip.
|
||||||
|
- A **WLED device** connected and reachable by LedGrab.
|
||||||
|
|
||||||
|
### How to Start
|
||||||
|
|
||||||
|
1. Open the **Calibration** modal for your strip source (pencil icon → Calibration tab).
|
||||||
|
2. Click the **Auto-calibrate** button in the modal footer.
|
||||||
|
3. Follow the five-step wizard.
|
||||||
|
|
||||||
|
### Wizard Steps
|
||||||
|
|
||||||
|
| Step | What you do |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| 1. Device | Select the WLED device that drives the strip. |
|
||||||
|
| 2. Start corner | LED #0 lights up on your device. Tap the corner where you see it. |
|
||||||
|
| 3. Direction | Sweep a few LEDs light up in sequence. Tap the direction they move. |
|
||||||
|
| 4. Mark corners | Use the step buttons to sweep to each remaining corner, then tap **Mark corner**. Repeat for all 4 corners. |
|
||||||
|
| 5. Preview & Save | Review the detected layout (start position, direction, LED counts per edge). Click **Save** to apply. |
|
||||||
|
|
||||||
|
### What Happens in the Background
|
||||||
|
|
||||||
|
- A calibration session takes exclusive control of the device for the duration of the wizard;
|
||||||
|
any previously running effect is paused and automatically restored when the wizard exits
|
||||||
|
(whether by saving, cancelling, or closing the modal).
|
||||||
|
- The solved `CalibrationConfig` is written directly to the Color Strip Source via the existing
|
||||||
|
PUT endpoint and takes effect immediately (no restart needed).
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- If LED #0 is hard to see, reduce ambient lighting briefly.
|
||||||
|
- The wizard works in the browser — desktop and Android TV app both supported.
|
||||||
|
- If you make a mistake in step 4, use **Step back** to re-mark the previous corner.
|
||||||
|
|
||||||
|
## Manual Calibration
|
||||||
|
|
||||||
### Step 1: Identify Your LED Layout
|
### Step 1: Identify Your LED Layout
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ auth:
|
|||||||
# with a secret you generated yourself (e.g. `openssl rand -hex 32`).
|
# with a secret you generated yourself (e.g. `openssl rand -hex 32`).
|
||||||
# Do NOT ship a hard-coded key here — a publicly-known token grants full
|
# Do NOT ship a hard-coded key here — a publicly-known token grants full
|
||||||
# LAN access to anyone on the network.
|
# LAN access to anyone on the network.
|
||||||
api_keys: {}
|
api_keys:
|
||||||
|
default: "development-key-change-in-production"
|
||||||
# api_keys:
|
# api_keys:
|
||||||
# my-client: "replace-with-output-of-openssl-rand-hex-32"
|
# my-client: "replace-with-output-of-openssl-rand-hex-32"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ledgrab"
|
name = "ledgrab"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .routes.audio_templates import router as audio_templates_router
|
|||||||
from .routes.value_sources import router as value_sources_router
|
from .routes.value_sources import router as value_sources_router
|
||||||
from .routes.automations import router as automations_router
|
from .routes.automations import router as automations_router
|
||||||
from .routes.scene_presets import router as scene_presets_router
|
from .routes.scene_presets import router as scene_presets_router
|
||||||
|
from .routes.scene_playlists import router as scene_playlists_router
|
||||||
from .routes.webhooks import router as webhooks_router
|
from .routes.webhooks import router as webhooks_router
|
||||||
from .routes.sync_clocks import router as sync_clocks_router
|
from .routes.sync_clocks import router as sync_clocks_router
|
||||||
from .routes.color_strip_processing import router as cspt_router
|
from .routes.color_strip_processing import router as cspt_router
|
||||||
@@ -35,6 +36,8 @@ from .routes.pattern_templates import router as pattern_templates_router
|
|||||||
from .routes.preferences import router as preferences_router
|
from .routes.preferences import router as preferences_router
|
||||||
from .routes.snapshot import router as snapshot_router
|
from .routes.snapshot import router as snapshot_router
|
||||||
from .routes.graph import router as graph_router
|
from .routes.graph import router as graph_router
|
||||||
|
from .routes.calibration import router as calibration_router
|
||||||
|
from .routes.setup import router as setup_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -53,6 +56,7 @@ router.include_router(output_targets_router)
|
|||||||
router.include_router(output_targets_control_router)
|
router.include_router(output_targets_control_router)
|
||||||
router.include_router(automations_router)
|
router.include_router(automations_router)
|
||||||
router.include_router(scene_presets_router)
|
router.include_router(scene_presets_router)
|
||||||
|
router.include_router(scene_playlists_router)
|
||||||
router.include_router(webhooks_router)
|
router.include_router(webhooks_router)
|
||||||
router.include_router(sync_clocks_router)
|
router.include_router(sync_clocks_router)
|
||||||
router.include_router(cspt_router)
|
router.include_router(cspt_router)
|
||||||
@@ -70,5 +74,7 @@ router.include_router(pattern_templates_router)
|
|||||||
router.include_router(preferences_router)
|
router.include_router(preferences_router)
|
||||||
router.include_router(snapshot_router)
|
router.include_router(snapshot_router)
|
||||||
router.include_router(graph_router)
|
router.include_router(graph_router)
|
||||||
|
router.include_router(calibration_router)
|
||||||
|
router.include_router(setup_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ledgrab.storage.audio_template_store import AudioTemplateStore
|
|||||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||||
from ledgrab.storage.automation_store import AutomationStore
|
from ledgrab.storage.automation_store import AutomationStore
|
||||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||||
from ledgrab.storage.color_strip_processing_template_store import (
|
from ledgrab.storage.color_strip_processing_template_store import (
|
||||||
ColorStripProcessingTemplateStore,
|
ColorStripProcessingTemplateStore,
|
||||||
@@ -27,6 +28,7 @@ from ledgrab.storage.gradient_store import GradientStore
|
|||||||
from ledgrab.storage.weather_source_store import WeatherSourceStore
|
from ledgrab.storage.weather_source_store import WeatherSourceStore
|
||||||
from ledgrab.storage.asset_store import AssetStore
|
from ledgrab.storage.asset_store import AssetStore
|
||||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||||
|
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
||||||
from ledgrab.core.weather.weather_manager import WeatherManager
|
from ledgrab.core.weather.weather_manager import WeatherManager
|
||||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||||
@@ -110,6 +112,14 @@ def get_automation_engine() -> AutomationEngine:
|
|||||||
return _get("automation_engine", "Automation engine")
|
return _get("automation_engine", "Automation engine")
|
||||||
|
|
||||||
|
|
||||||
|
def get_scene_playlist_store() -> ScenePlaylistStore:
|
||||||
|
return _get("scene_playlist_store", "Scene playlist store")
|
||||||
|
|
||||||
|
|
||||||
|
def get_playlist_engine() -> PlaylistEngine:
|
||||||
|
return _get("playlist_engine", "Playlist engine")
|
||||||
|
|
||||||
|
|
||||||
def get_auto_backup_engine() -> AutoBackupEngine:
|
def get_auto_backup_engine() -> AutoBackupEngine:
|
||||||
return _get("auto_backup_engine", "Auto-backup engine")
|
return _get("auto_backup_engine", "Auto-backup engine")
|
||||||
|
|
||||||
@@ -226,7 +236,9 @@ def init_dependencies(
|
|||||||
value_source_store: ValueSourceStore | None = None,
|
value_source_store: ValueSourceStore | None = None,
|
||||||
automation_store: AutomationStore | None = None,
|
automation_store: AutomationStore | None = None,
|
||||||
scene_preset_store: ScenePresetStore | None = None,
|
scene_preset_store: ScenePresetStore | None = None,
|
||||||
|
scene_playlist_store: ScenePlaylistStore | None = None,
|
||||||
automation_engine: AutomationEngine | None = None,
|
automation_engine: AutomationEngine | None = None,
|
||||||
|
playlist_engine: PlaylistEngine | None = None,
|
||||||
auto_backup_engine: AutoBackupEngine | None = None,
|
auto_backup_engine: AutoBackupEngine | None = None,
|
||||||
sync_clock_store: SyncClockStore | None = None,
|
sync_clock_store: SyncClockStore | None = None,
|
||||||
sync_clock_manager: SyncClockManager | None = None,
|
sync_clock_manager: SyncClockManager | None = None,
|
||||||
@@ -262,7 +274,9 @@ def init_dependencies(
|
|||||||
"value_source_store": value_source_store,
|
"value_source_store": value_source_store,
|
||||||
"automation_store": automation_store,
|
"automation_store": automation_store,
|
||||||
"scene_preset_store": scene_preset_store,
|
"scene_preset_store": scene_preset_store,
|
||||||
|
"scene_playlist_store": scene_playlist_store,
|
||||||
"automation_engine": automation_engine,
|
"automation_engine": automation_engine,
|
||||||
|
"playlist_engine": playlist_engine,
|
||||||
"auto_backup_engine": auto_backup_engine,
|
"auto_backup_engine": auto_backup_engine,
|
||||||
"sync_clock_store": sync_clock_store,
|
"sync_clock_store": sync_clock_store,
|
||||||
"sync_clock_manager": sync_clock_manager,
|
"sync_clock_manager": sync_clock_manager,
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
|
|||||||
"time_of_day": lambda: TimeOfDayRule(
|
"time_of_day": lambda: TimeOfDayRule(
|
||||||
start_time=s.start_time or "00:00",
|
start_time=s.start_time or "00:00",
|
||||||
end_time=s.end_time or "23:59",
|
end_time=s.end_time or "23:59",
|
||||||
|
days_of_week=s.days_of_week or [],
|
||||||
|
timezone=s.timezone or "",
|
||||||
),
|
),
|
||||||
"system_idle": lambda: SystemIdleRule(
|
"system_idle": lambda: SystemIdleRule(
|
||||||
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Calibration session and solver API routes.
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
---------
|
||||||
|
POST /api/v1/calibration/session
|
||||||
|
Start a calibration session on a device (stops any running target on that
|
||||||
|
device and remembers it for restore on stop).
|
||||||
|
|
||||||
|
POST /api/v1/calibration/session/position
|
||||||
|
Advance the chase pixel to a specific LED index on the active device.
|
||||||
|
|
||||||
|
POST /api/v1/calibration/session/stop
|
||||||
|
End the session: clear the device to black and restore the prior target.
|
||||||
|
|
||||||
|
POST /api/v1/calibration/session/cancel
|
||||||
|
Alias for stop (does not apply any solved calibration).
|
||||||
|
|
||||||
|
GET /api/v1/calibration/session/state
|
||||||
|
Return the current session state (active, device, last_activity, …).
|
||||||
|
|
||||||
|
POST /api/v1/calibration/solve
|
||||||
|
Pure-logic: solve a CalibrationConfig from 4 corner tap indices.
|
||||||
|
Does NOT persist — the caller must follow up with
|
||||||
|
``PUT /api/v1/color-strip-sources/{id}`` to persist.
|
||||||
|
|
||||||
|
Persist path
|
||||||
|
------------
|
||||||
|
The existing ``PUT /api/v1/color-strip-sources/{id}`` already accepts a
|
||||||
|
``calibration`` field on ``PictureCSSUpdate`` / ``PictureAdvancedCSSUpdate``
|
||||||
|
and hot-reloads running streams automatically (see
|
||||||
|
``api/routes/color_strip_sources/crud.py``). There is NO duplicate endpoint
|
||||||
|
here. Phase 3 UI calls the existing PUT to persist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.api.dependencies import get_processor_manager
|
||||||
|
from ledgrab.api.schemas.calibration import (
|
||||||
|
CalibrationSessionPositionRequest,
|
||||||
|
CalibrationSessionStartRequest,
|
||||||
|
CalibrationSessionStateResponse,
|
||||||
|
CalibrationSolveRequest,
|
||||||
|
CalibrationSolvedResponse,
|
||||||
|
)
|
||||||
|
from ledgrab.core.capture.calibration import solve_calibration
|
||||||
|
from ledgrab.core.capture.calibration_session import get_calibration_session
|
||||||
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session endpoints ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def start_calibration_session(
|
||||||
|
body: CalibrationSessionStartRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Start a calibration session on a device.
|
||||||
|
|
||||||
|
Stops any target currently processing on that device (it will be restored
|
||||||
|
when the session ends). Only one session can be active at a time; starting
|
||||||
|
a new one terminates the previous one first.
|
||||||
|
"""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.start(body.device_id, manager)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to start calibration session: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSessionStateResponse(**session.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session/position",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def calibration_session_position(
|
||||||
|
body: CalibrationSessionPositionRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Advance the chase pixel to a specific LED index on the active device.
|
||||||
|
|
||||||
|
``index`` must be 0-based and < ``led_count``. Returns 422 when out of
|
||||||
|
range (Pydantic ``ge=0``) or 400 if the session is not active / index
|
||||||
|
exceeds led_count.
|
||||||
|
"""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.position(body.index, body.window)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to set calibration pixel index=%d: %s", body.index, exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSessionStateResponse(**session.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session/stop",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def stop_calibration_session(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""End the calibration session.
|
||||||
|
|
||||||
|
Clears the device to black and restores the previously-running target (if
|
||||||
|
any). Safe to call even when no session is active (returns inactive state).
|
||||||
|
"""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.stop()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to stop calibration session: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSessionStateResponse(**session.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session/cancel",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def cancel_calibration_session(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Cancel the calibration session (alias for stop — no calibration is applied)."""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.cancel()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to cancel calibration session: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSessionStateResponse(**session.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/calibration/session/state",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def get_calibration_session_state(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Return the current calibration session state."""
|
||||||
|
return CalibrationSessionStateResponse(**get_calibration_session().get_state())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Solver endpoint ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
response_model=CalibrationSolvedResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def solve_calibration_endpoint(
|
||||||
|
body: CalibrationSolveRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
) -> CalibrationSolvedResponse:
|
||||||
|
"""Solve a CalibrationConfig from 4 corner tap indices.
|
||||||
|
|
||||||
|
Returns the computed per-edge LED counts. Does NOT persist — call
|
||||||
|
``PUT /api/v1/color-strip-sources/{id}`` with ``calibration`` in the body
|
||||||
|
to save.
|
||||||
|
|
||||||
|
Provide either *device_id* (preferred, server derives led_count) or
|
||||||
|
*led_count* directly. Returns 404 if *device_id* is not found, 422 on
|
||||||
|
invalid enum values, 400 on logical errors (e.g. corner_indices length).
|
||||||
|
"""
|
||||||
|
# Resolve led_count
|
||||||
|
led_count = body.led_count
|
||||||
|
if body.device_id is not None:
|
||||||
|
if body.device_id not in manager._devices:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Device {body.device_id!r} not found",
|
||||||
|
)
|
||||||
|
ds = manager._devices[body.device_id]
|
||||||
|
led_count = ds.led_count
|
||||||
|
|
||||||
|
if led_count is None or led_count <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="led_count must be a positive integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = solve_calibration(
|
||||||
|
led_count=led_count,
|
||||||
|
start_position=body.start_position,
|
||||||
|
layout=body.layout,
|
||||||
|
corner_indices=body.corner_indices,
|
||||||
|
offset=body.offset,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to solve calibration: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSolvedResponse(
|
||||||
|
mode="simple",
|
||||||
|
layout=cfg.layout,
|
||||||
|
start_position=cfg.start_position,
|
||||||
|
leds_top=cfg.leds_top,
|
||||||
|
leds_right=cfg.leds_right,
|
||||||
|
leds_bottom=cfg.leds_bottom,
|
||||||
|
leds_left=cfg.leds_left,
|
||||||
|
offset=cfg.offset,
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ adapter metadata, and diagnostics.
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -57,6 +58,73 @@ _prev_states: dict[str, dict[str, Any]] = {}
|
|||||||
_integration_stats: dict[str, dict[str, Any]] = {}
|
_integration_stats: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
|
||||||
|
#
|
||||||
|
# The ingest route is high-frequency (games push at 16-64 Hz), so we do NOT
|
||||||
|
# rate-limit every event — that would throttle legitimate gameplay traffic.
|
||||||
|
# Instead we throttle only FAILED-auth attempts per source IP (the only thing
|
||||||
|
# an attacker without the token can produce). This mirrors the IP-based
|
||||||
|
# limiter in routes/webhooks.py (~30/min) but scopes it to failures so a
|
||||||
|
# brute-forcer is locked out after _AUTH_FAIL_LIMIT bad tokens per minute
|
||||||
|
# while authenticated high-rate ingestion is completely unaffected.
|
||||||
|
_AUTH_FAIL_LIMIT = 30
|
||||||
|
_AUTH_FAIL_WINDOW = 60.0 # seconds
|
||||||
|
_AUTH_FAIL_HITS_HARD_CAP = 1024
|
||||||
|
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||||
|
_auth_fail_hits: dict[str, list[float]] = defaultdict(list)
|
||||||
|
_auth_fail_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit_key(request: Request) -> str:
|
||||||
|
"""Pick a stable client identifier for rate-limiting.
|
||||||
|
|
||||||
|
When the immediate peer is loopback (assumed reverse-proxy), use the
|
||||||
|
first ``X-Forwarded-For`` entry; otherwise use the peer's IP.
|
||||||
|
"""
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
if peer in _LOOPBACK_HOSTS:
|
||||||
|
xff = request.headers.get("x-forwarded-for", "")
|
||||||
|
if xff:
|
||||||
|
return xff.split(",", 1)[0].strip() or peer
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
|
def _check_auth_fail_rate_limit(client_ip: str) -> None:
|
||||||
|
"""Raise 429 if *client_ip* exceeded the failed-auth attempt limit."""
|
||||||
|
now = time.time()
|
||||||
|
window_start = now - _AUTH_FAIL_WINDOW
|
||||||
|
with _auth_fail_lock:
|
||||||
|
timestamps = [t for t in _auth_fail_hits[client_ip] if t > window_start]
|
||||||
|
_auth_fail_hits[client_ip] = timestamps
|
||||||
|
if len(timestamps) >= _AUTH_FAIL_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many failed authentication attempts. Try again later.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_auth_failure(client_ip: str) -> None:
|
||||||
|
"""Record a failed-auth attempt for *client_ip* (bounded memory)."""
|
||||||
|
now = time.time()
|
||||||
|
window_start = now - _AUTH_FAIL_WINDOW
|
||||||
|
with _auth_fail_lock:
|
||||||
|
_auth_fail_hits[client_ip].append(now)
|
||||||
|
# Periodic cleanup of stale IPs to prevent unbounded growth.
|
||||||
|
if len(_auth_fail_hits) > 100:
|
||||||
|
stale = [ip for ip, ts in _auth_fail_hits.items() if not ts or ts[-1] < window_start]
|
||||||
|
for ip in stale:
|
||||||
|
del _auth_fail_hits[ip]
|
||||||
|
# Hard cap against an attacker spraying many distinct X-Forwarded-For
|
||||||
|
# values; drop the oldest-touched IPs.
|
||||||
|
if len(_auth_fail_hits) > _AUTH_FAIL_HITS_HARD_CAP:
|
||||||
|
ordered = sorted(
|
||||||
|
_auth_fail_hits.items(),
|
||||||
|
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
|
||||||
|
)
|
||||||
|
for ip, _ in ordered[: len(ordered) - _AUTH_FAIL_HITS_HARD_CAP]:
|
||||||
|
_auth_fail_hits.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
"""Convert a JSON Schema object into a flat list of field descriptors.
|
"""Convert a JSON Schema object into a flat list of field descriptors.
|
||||||
|
|
||||||
@@ -387,7 +455,16 @@ async def ingest_event(
|
|||||||
called before standard API auth.
|
called before standard API auth.
|
||||||
|
|
||||||
No AuthRequired dependency — adapter-level auth is used instead.
|
No AuthRequired dependency — adapter-level auth is used instead.
|
||||||
|
|
||||||
|
Rate limiting is scoped to FAILED-auth attempts per source IP (see
|
||||||
|
``_check_auth_fail_rate_limit``) so legitimate high-rate ingestion is
|
||||||
|
never throttled, but a brute-forcer is locked out after the threshold.
|
||||||
"""
|
"""
|
||||||
|
client_ip = _rate_limit_key(request)
|
||||||
|
# Block IPs that have already burned through the failed-auth budget,
|
||||||
|
# before doing any work (cheap brute-force lockout).
|
||||||
|
_check_auth_fail_rate_limit(client_ip)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = store.get_integration(integration_id)
|
config = store.get_integration(integration_id)
|
||||||
except EntityNotFoundError:
|
except EntityNotFoundError:
|
||||||
@@ -405,6 +482,7 @@ async def ingest_event(
|
|||||||
# Adapter-level auth check
|
# Adapter-level auth check
|
||||||
headers = dict(request.headers)
|
headers = dict(request.headers)
|
||||||
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
|
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
|
||||||
|
_record_auth_failure(client_ip)
|
||||||
raise HTTPException(status_code=403, detail="Adapter authentication failed")
|
raise HTTPException(status_code=403, detail="Adapter authentication failed")
|
||||||
|
|
||||||
# Parse payload through adapter
|
# Parse payload through adapter
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ from ledgrab.storage.base_store import EntityNotFoundError
|
|||||||
from ledgrab.storage.home_assistant_source import HomeAssistantSource
|
from ledgrab.storage.home_assistant_source import HomeAssistantSource
|
||||||
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.net_classify import validate_lan_host
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -37,6 +39,23 @@ router = APIRouter()
|
|||||||
_REDACTED_TOKEN = "***"
|
_REDACTED_TOKEN = "***"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ha_host(host: str | None) -> None:
|
||||||
|
"""Reject literal public/link-local/metadata IPs for a HA source host.
|
||||||
|
|
||||||
|
HA sources are LAN-by-design (loopback + private ranges allowed), so we
|
||||||
|
gate the user-supplied ``host`` with the same shared classifier the LED
|
||||||
|
device providers use (``validate_lan_host``). The HA host is stored as
|
||||||
|
``host:port`` (e.g. ``192.168.1.100:8123``), so strip the port first via
|
||||||
|
``urlparse`` — which also handles bracketed IPv6 literals. Hostnames /
|
||||||
|
mDNS labels pass through (classified UNPARSEABLE). Raises ``ValueError``
|
||||||
|
on a literal public IP, which the callers translate to HTTP 400.
|
||||||
|
"""
|
||||||
|
if not host:
|
||||||
|
return
|
||||||
|
bare_host = urlparse(f"//{host.strip()}").hostname or host.strip()
|
||||||
|
validate_lan_host(bare_host)
|
||||||
|
|
||||||
|
|
||||||
def _to_response(
|
def _to_response(
|
||||||
source: HomeAssistantSource,
|
source: HomeAssistantSource,
|
||||||
manager: HomeAssistantManager,
|
manager: HomeAssistantManager,
|
||||||
@@ -99,6 +118,7 @@ async def create_ha_source(
|
|||||||
manager: HomeAssistantManager = Depends(get_ha_manager),
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
_validate_ha_host(data.host)
|
||||||
source = store.create_source(
|
source = store.create_source(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
host=data.host,
|
host=data.host,
|
||||||
@@ -153,6 +173,7 @@ async def update_ha_source(
|
|||||||
manager: HomeAssistantManager = Depends(get_ha_manager),
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
_validate_ha_host(data.host)
|
||||||
source = store.update_source(
|
source = store.update_source(
|
||||||
source_id,
|
source_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
|
|||||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||||
adaptive_fps=target.adaptive_fps,
|
adaptive_fps=target.adaptive_fps,
|
||||||
protocol=target.protocol,
|
protocol=target.protocol,
|
||||||
|
max_milliamps=target.max_milliamps,
|
||||||
|
milliamps_per_led=target.milliamps_per_led,
|
||||||
description=target.description,
|
description=target.description,
|
||||||
tags=target.tags,
|
tags=target.tags,
|
||||||
icon=getattr(target, "icon", "") or "",
|
icon=getattr(target, "icon", "") or "",
|
||||||
@@ -302,6 +304,8 @@ async def create_target(
|
|||||||
min_brightness_threshold=data.min_brightness_threshold,
|
min_brightness_threshold=data.min_brightness_threshold,
|
||||||
adaptive_fps=data.adaptive_fps,
|
adaptive_fps=data.adaptive_fps,
|
||||||
protocol=data.protocol,
|
protocol=data.protocol,
|
||||||
|
max_milliamps=data.max_milliamps,
|
||||||
|
milliamps_per_led=data.milliamps_per_led,
|
||||||
)
|
)
|
||||||
case HALightOutputTargetCreate():
|
case HALightOutputTargetCreate():
|
||||||
if data.source_kind == "color_vs":
|
if data.source_kind == "color_vs":
|
||||||
@@ -464,6 +468,8 @@ async def update_target(
|
|||||||
min_brightness_threshold=data.min_brightness_threshold,
|
min_brightness_threshold=data.min_brightness_threshold,
|
||||||
adaptive_fps=data.adaptive_fps,
|
adaptive_fps=data.adaptive_fps,
|
||||||
protocol=data.protocol,
|
protocol=data.protocol,
|
||||||
|
max_milliamps=data.max_milliamps,
|
||||||
|
milliamps_per_led=data.milliamps_per_led,
|
||||||
)
|
)
|
||||||
css_changed = data.color_strip_source_id is not None
|
css_changed = data.color_strip_source_id is not None
|
||||||
brightness_changed = data.brightness is not None
|
brightness_changed = data.brightness is not None
|
||||||
@@ -476,6 +482,8 @@ async def update_target(
|
|||||||
data.min_brightness_threshold,
|
data.min_brightness_threshold,
|
||||||
data.adaptive_fps,
|
data.adaptive_fps,
|
||||||
data.brightness,
|
data.brightness,
|
||||||
|
data.max_milliamps,
|
||||||
|
data.milliamps_per_led,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
device_changed = data.device_id is not None
|
device_changed = data.device_id is not None
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
|||||||
tags=t.tags,
|
tags=t.tags,
|
||||||
icon=getattr(t, "icon", "") or "",
|
icon=getattr(t, "icon", "") or "",
|
||||||
icon_color=getattr(t, "icon_color", "") or "",
|
icon_color=getattr(t, "icon_color", "") or "",
|
||||||
|
is_builtin=getattr(t, "is_builtin", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ daylight value-source / color-strip-source. Stored as
|
|||||||
empty/missing meaning "use system local time".
|
empty/missing meaning "use system local time".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
@@ -38,6 +39,7 @@ router = APIRouter()
|
|||||||
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
||||||
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
||||||
_CARD_MODES_KEY = "card_modes"
|
_CARD_MODES_KEY = "card_modes"
|
||||||
|
_ONBOARDING_KEY = "onboarded"
|
||||||
|
|
||||||
|
|
||||||
class DaylightTimezonePreference(BaseModel):
|
class DaylightTimezonePreference(BaseModel):
|
||||||
@@ -285,4 +287,75 @@ async def put_daylight_timezone_preference(
|
|||||||
return DaylightTimezonePreference(timezone=saved)
|
return DaylightTimezonePreference(timezone=saved)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Onboarding flag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingPreference(BaseModel):
|
||||||
|
"""Persistent first-run onboarding flag."""
|
||||||
|
|
||||||
|
onboarded: bool = Field(
|
||||||
|
False,
|
||||||
|
description="True once the user has completed the first-run wizard.",
|
||||||
|
)
|
||||||
|
completed_at: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="ISO timestamp of when onboarding was first marked complete; null otherwise.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/preferences/onboarding",
|
||||||
|
response_model=OnboardingPreference,
|
||||||
|
tags=["Preferences"],
|
||||||
|
)
|
||||||
|
async def get_onboarding(
|
||||||
|
_: AuthRequired,
|
||||||
|
db: Database = Depends(get_database),
|
||||||
|
) -> OnboardingPreference:
|
||||||
|
"""Return the first-run onboarding status.
|
||||||
|
|
||||||
|
Defaults to ``{onboarded: false, completed_at: null}`` when the flag has
|
||||||
|
never been set.
|
||||||
|
"""
|
||||||
|
raw = db.get_setting(_ONBOARDING_KEY)
|
||||||
|
if not raw:
|
||||||
|
return OnboardingPreference()
|
||||||
|
try:
|
||||||
|
return OnboardingPreference.model_validate(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Stored onboarding preference invalid (%s); using default", exc)
|
||||||
|
return OnboardingPreference()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/preferences/onboarding",
|
||||||
|
response_model=OnboardingPreference,
|
||||||
|
tags=["Preferences"],
|
||||||
|
)
|
||||||
|
async def put_onboarding(
|
||||||
|
_: AuthRequired,
|
||||||
|
body: OnboardingPreference,
|
||||||
|
db: Database = Depends(get_database),
|
||||||
|
) -> OnboardingPreference:
|
||||||
|
"""Persist the onboarding flag.
|
||||||
|
|
||||||
|
When ``onboarded`` is set to ``true`` and ``completed_at`` is not provided,
|
||||||
|
the server stamps the current UTC time automatically.
|
||||||
|
When ``onboarded`` is ``false``, ``completed_at`` is cleared.
|
||||||
|
"""
|
||||||
|
if body.onboarded and body.completed_at is None:
|
||||||
|
body = OnboardingPreference(
|
||||||
|
onboarded=True,
|
||||||
|
completed_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
elif not body.onboarded:
|
||||||
|
body = OnboardingPreference(onboarded=False, completed_at=None)
|
||||||
|
|
||||||
|
db.set_setting(_ONBOARDING_KEY, body.model_dump())
|
||||||
|
logger.info("Onboarding flag updated: onboarded=%s", body.onboarded)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
|
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Scene playlist API routes — CRUD plus start/stop/state cycling control."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_playlist_engine,
|
||||||
|
get_scene_playlist_store,
|
||||||
|
get_scene_preset_store,
|
||||||
|
)
|
||||||
|
from ledgrab.api.schemas.scene_playlists import (
|
||||||
|
PlaylistRuntimeStateSchema,
|
||||||
|
ScenePlaylistCreate,
|
||||||
|
ScenePlaylistListResponse,
|
||||||
|
ScenePlaylistResponse,
|
||||||
|
ScenePlaylistUpdate,
|
||||||
|
)
|
||||||
|
from ledgrab.core.scenes.playlist_engine import PlaylistEngine, PlaylistError
|
||||||
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist
|
||||||
|
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||||
|
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _playlist_to_response(playlist: ScenePlaylist, engine: PlaylistEngine) -> ScenePlaylistResponse:
|
||||||
|
return ScenePlaylistResponse(
|
||||||
|
id=playlist.id,
|
||||||
|
name=playlist.name,
|
||||||
|
description=playlist.description,
|
||||||
|
items=[
|
||||||
|
{"scene_preset_id": i.scene_preset_id, "duration_seconds": i.duration_seconds}
|
||||||
|
for i in playlist.items
|
||||||
|
],
|
||||||
|
loop=playlist.loop,
|
||||||
|
shuffle=playlist.shuffle,
|
||||||
|
order=playlist.order,
|
||||||
|
tags=playlist.tags,
|
||||||
|
icon=getattr(playlist, "icon", "") or "",
|
||||||
|
icon_color=getattr(playlist, "icon_color", "") or "",
|
||||||
|
is_running=engine.get_running_playlist_id() == playlist.id,
|
||||||
|
created_at=playlist.created_at,
|
||||||
|
updated_at=playlist.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _items_from_schema(items) -> list[PlaylistItem]:
|
||||||
|
return [
|
||||||
|
PlaylistItem(scene_preset_id=i.scene_preset_id, duration_seconds=i.duration_seconds)
|
||||||
|
for i in items
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_preset_refs(items, preset_store: ScenePresetStore) -> None:
|
||||||
|
"""Reject playlist items that reference a non-existent scene preset."""
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
preset_store.get_preset(item.scene_preset_id)
|
||||||
|
except (ValueError, EntityNotFoundError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Scene preset not found: {item.scene_preset_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CRUD =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-playlists",
|
||||||
|
response_model=ScenePlaylistResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_scene_playlist(
|
||||||
|
data: ScenePlaylistCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Create a new scene playlist."""
|
||||||
|
_validate_preset_refs(data.items, preset_store)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
playlist = ScenePlaylist(
|
||||||
|
id=f"playlist_{uuid.uuid4().hex[:8]}",
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
items=_items_from_schema(data.items),
|
||||||
|
loop=data.loop,
|
||||||
|
shuffle=data.shuffle,
|
||||||
|
order=store.count(),
|
||||||
|
tags=data.tags if data.tags is not None else [],
|
||||||
|
icon=data.icon or "",
|
||||||
|
icon_color=data.icon_color or "",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = store.create_playlist(playlist)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_playlist", "created", playlist.id)
|
||||||
|
return _playlist_to_response(playlist, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-playlists",
|
||||||
|
response_model=ScenePlaylistListResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def list_scene_playlists(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""List all scene playlists plus the current cycling state."""
|
||||||
|
playlists = store.get_all_playlists()
|
||||||
|
return ScenePlaylistListResponse(
|
||||||
|
playlists=[_playlist_to_response(p, engine) for p in playlists],
|
||||||
|
count=len(playlists),
|
||||||
|
state=PlaylistRuntimeStateSchema(**engine.get_state()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: the static ``/state`` path is declared before ``/{playlist_id}`` so it
|
||||||
|
# is matched first and not swallowed by the path parameter.
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-playlists/state",
|
||||||
|
response_model=PlaylistRuntimeStateSchema,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def get_playlist_state(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Get the current playlist cycling state (idle if nothing is running)."""
|
||||||
|
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}",
|
||||||
|
response_model=ScenePlaylistResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def get_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Get a single scene playlist."""
|
||||||
|
try:
|
||||||
|
playlist = store.get_playlist(playlist_id)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
return _playlist_to_response(playlist, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}",
|
||||||
|
response_model=ScenePlaylistResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def update_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
data: ScenePlaylistUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Update a scene playlist's metadata, items, and playback flags."""
|
||||||
|
new_items = None
|
||||||
|
if data.items is not None:
|
||||||
|
_validate_preset_refs(data.items, preset_store)
|
||||||
|
new_items = _items_from_schema(data.items)
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = store.update_playlist(
|
||||||
|
playlist_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
items=new_items,
|
||||||
|
loop=data.loop,
|
||||||
|
shuffle=data.shuffle,
|
||||||
|
order=data.order,
|
||||||
|
tags=data.tags,
|
||||||
|
icon=data.icon,
|
||||||
|
icon_color=data.icon_color,
|
||||||
|
)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
fire_entity_event("scene_playlist", "updated", playlist_id)
|
||||||
|
return _playlist_to_response(playlist, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}",
|
||||||
|
status_code=204,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def delete_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Delete a scene playlist (stops it first if it is currently cycling)."""
|
||||||
|
try:
|
||||||
|
store.delete_playlist(playlist_id)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
await engine.stop_if_running(playlist_id)
|
||||||
|
fire_entity_event("scene_playlist", "deleted", playlist_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Cycling control =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}/start",
|
||||||
|
response_model=PlaylistRuntimeStateSchema,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def start_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Start cycling a playlist (stops any currently-running playlist first)."""
|
||||||
|
try:
|
||||||
|
store.get_playlist(playlist_id)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await engine.start_playlist(playlist_id)
|
||||||
|
except PlaylistError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_playlist", "updated", playlist_id)
|
||||||
|
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-playlists/stop",
|
||||||
|
response_model=PlaylistRuntimeStateSchema,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def stop_scene_playlist(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Stop the active playlist (leaves the last applied scene in place)."""
|
||||||
|
stopped_id = engine.get_running_playlist_id()
|
||||||
|
await engine.stop()
|
||||||
|
if stopped_id:
|
||||||
|
fire_entity_event("scene_playlist", "updated", stopped_id)
|
||||||
|
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""Setup scaffold endpoint.
|
||||||
|
|
||||||
|
Wires a complete capture → color-strip → output chain in one call, with
|
||||||
|
automatic rollback if any step fails so no orphan entities are left behind.
|
||||||
|
|
||||||
|
POST /api/v1/setup/scaffold
|
||||||
|
Body: ScaffoldRequest — device_id (required, must already exist),
|
||||||
|
display_index, optional calibration dict.
|
||||||
|
Returns: ScaffoldResponse — ids of every created/reused entity.
|
||||||
|
Fires ``entity_changed`` events for every entity created in this call,
|
||||||
|
but ONLY after the full chain succeeds (no mid-chain events).
|
||||||
|
Does NOT auto-start the target (the frontend starts it after calibration).
|
||||||
|
|
||||||
|
Rollback contract
|
||||||
|
-----------------
|
||||||
|
Entities created during THIS request are tracked in a local list. If any
|
||||||
|
step raises, they are deleted in reverse-creation order before re-raising.
|
||||||
|
Because "created" events are deferred until after the chain completes, a
|
||||||
|
rollback never produces ghost UI cards — no event for a rolled-back entity
|
||||||
|
is ever emitted.
|
||||||
|
|
||||||
|
The device is never part of the rollback set: scaffold requires an existing
|
||||||
|
device (created via ``POST /api/v1/devices`` which runs full validation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_color_strip_store,
|
||||||
|
get_device_store,
|
||||||
|
get_output_target_store,
|
||||||
|
get_picture_source_store,
|
||||||
|
get_processor_manager,
|
||||||
|
get_template_store,
|
||||||
|
)
|
||||||
|
from ledgrab.api.schemas.setup import ScaffoldRequest, ScaffoldResponse
|
||||||
|
from ledgrab.core.capture.calibration import calibration_from_dict, create_default_calibration
|
||||||
|
from ledgrab.core.capture_engines.factory import EngineRegistry
|
||||||
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||||
|
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||||
|
from ledgrab.storage.picture_source_store import PictureSourceStore
|
||||||
|
from ledgrab.storage import DeviceStore
|
||||||
|
from ledgrab.storage.template_store import TemplateStore
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_DEFAULT_TARGET_FPS = 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: capture template
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_capture_template(
|
||||||
|
template_store: TemplateStore,
|
||||||
|
created_ids: list[tuple[str, str]],
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
"""Return (template_id, reused).
|
||||||
|
|
||||||
|
Tries to find an existing template whose engine_type matches the platform's
|
||||||
|
best available engine. Falls back to creating a fresh one.
|
||||||
|
"""
|
||||||
|
best_engine = EngineRegistry.get_best_available_engine()
|
||||||
|
if not best_engine:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="No capture engine available on this platform; cannot scaffold.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to reuse an existing template with the same engine
|
||||||
|
for tpl in template_store.get_all_templates():
|
||||||
|
if tpl.engine_type == best_engine:
|
||||||
|
logger.info(
|
||||||
|
"Scaffold: reusing existing capture template %s (engine=%s)",
|
||||||
|
tpl.id,
|
||||||
|
best_engine,
|
||||||
|
)
|
||||||
|
return tpl.id, True
|
||||||
|
|
||||||
|
# None found — create a fresh one
|
||||||
|
engine_class = EngineRegistry.get_engine(best_engine)
|
||||||
|
default_config = engine_class.get_default_config()
|
||||||
|
try:
|
||||||
|
tpl = template_store.create_template(
|
||||||
|
name=f"Scaffold capture ({best_engine})",
|
||||||
|
engine_type=best_engine,
|
||||||
|
engine_config=default_config,
|
||||||
|
description="Auto-created by first-run scaffold",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
created_ids.append(("capture_template", tpl.id))
|
||||||
|
logger.info("Scaffold: created capture template %s (engine=%s)", tpl.id, best_engine)
|
||||||
|
return tpl.id, False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: rollback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _rollback(
|
||||||
|
created_ids: list[tuple[str, str]],
|
||||||
|
*,
|
||||||
|
template_store: TemplateStore,
|
||||||
|
picture_source_store: PictureSourceStore,
|
||||||
|
css_store: ColorStripStore,
|
||||||
|
output_target_store: OutputTargetStore,
|
||||||
|
manager: ProcessorManager | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete entities created during this call, in reverse order.
|
||||||
|
|
||||||
|
Only entities listed in ``created_ids`` are deleted; reused/pre-existing
|
||||||
|
entities (including the device) are never touched.
|
||||||
|
|
||||||
|
If *manager* is provided, any ``output_target`` entity in the rollback set
|
||||||
|
is also unregistered from the ProcessorManager before store deletion, so no
|
||||||
|
half-registered target is left behind.
|
||||||
|
"""
|
||||||
|
store_map: dict[str, Any] = {
|
||||||
|
"capture_template": template_store,
|
||||||
|
"picture_source": picture_source_store,
|
||||||
|
"color_strip_source": css_store,
|
||||||
|
"output_target": output_target_store,
|
||||||
|
}
|
||||||
|
for entity_type, entity_id in reversed(created_ids):
|
||||||
|
# Unregister output targets from the processor manager first
|
||||||
|
if entity_type == "output_target" and manager is not None:
|
||||||
|
try:
|
||||||
|
manager.remove_target(entity_id)
|
||||||
|
logger.info("Scaffold rollback: unregistered target %s from manager", entity_id)
|
||||||
|
except (ValueError, RuntimeError) as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Scaffold rollback: manager unregister skipped for %s — %s",
|
||||||
|
entity_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
store = store_map.get(entity_type)
|
||||||
|
if store is None:
|
||||||
|
logger.warning("Scaffold rollback: unknown entity type %r — skipping", entity_type)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
store.delete(entity_id)
|
||||||
|
logger.info("Scaffold rollback: deleted %s %s", entity_type, entity_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Scaffold rollback: failed to delete %s %s — %s",
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/setup/scaffold",
|
||||||
|
response_model=ScaffoldResponse,
|
||||||
|
status_code=201,
|
||||||
|
tags=["Setup"],
|
||||||
|
)
|
||||||
|
async def scaffold_setup(
|
||||||
|
data: ScaffoldRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
device_store: DeviceStore = Depends(get_device_store),
|
||||||
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
|
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
|
||||||
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
output_target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
) -> ScaffoldResponse:
|
||||||
|
"""Create a ready-to-start LED capture chain.
|
||||||
|
|
||||||
|
Steps (each uses the real store create method for validation and ID gen):
|
||||||
|
|
||||||
|
1. Look up the existing device (404 if not found).
|
||||||
|
2. Find or create a capture template for the platform-best engine.
|
||||||
|
3. Create a raw picture source (``display_index`` + ``capture_template_id``).
|
||||||
|
4. Create a picture color-strip source with either the provided calibration
|
||||||
|
or ``create_default_calibration(led_count)``.
|
||||||
|
5. Create a LED output target linking the device to the CSS.
|
||||||
|
|
||||||
|
All created entities emit ``entity_changed`` events, but ONLY after the
|
||||||
|
full chain succeeds — events are collected and fired at the very end.
|
||||||
|
On any error the entities created so far are deleted in reverse order
|
||||||
|
(rollback), and no "created" events are emitted (no ghost UI cards).
|
||||||
|
The output target is NOT started — the frontend starts it after the
|
||||||
|
optional calibration step.
|
||||||
|
"""
|
||||||
|
created_ids: list[tuple[str, str]] = []
|
||||||
|
# Deferred "created" events: (entity_type, entity_id) — fired only on success.
|
||||||
|
pending_events: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
rollback_stores = dict(
|
||||||
|
template_store=template_store,
|
||||||
|
picture_source_store=picture_source_store,
|
||||||
|
css_store=css_store,
|
||||||
|
output_target_store=output_target_store,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── Step 1: resolve existing device ─────────────────────────────────
|
||||||
|
try:
|
||||||
|
device = device_store.get(data.device_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Device not found: {data.device_id}")
|
||||||
|
device_id = device.id
|
||||||
|
led_count = device.led_count
|
||||||
|
|
||||||
|
# ── Step 2: capture template ─────────────────────────────────────────
|
||||||
|
capture_template_id, template_reused = _get_or_create_capture_template(
|
||||||
|
template_store, created_ids
|
||||||
|
)
|
||||||
|
if not template_reused:
|
||||||
|
pending_events.append(("capture_template", capture_template_id))
|
||||||
|
|
||||||
|
# ── Step 3: picture source ───────────────────────────────────────────
|
||||||
|
ps_name = f"Screen {data.display_index} (scaffold)"
|
||||||
|
try:
|
||||||
|
picture_source = picture_source_store.create_stream(
|
||||||
|
name=ps_name,
|
||||||
|
stream_type="raw",
|
||||||
|
display_index=data.display_index,
|
||||||
|
capture_template_id=capture_template_id,
|
||||||
|
target_fps=_DEFAULT_TARGET_FPS,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
created_ids.append(("picture_source", picture_source.id))
|
||||||
|
pending_events.append(("picture_source", picture_source.id))
|
||||||
|
logger.info("Scaffold: created picture source %s", picture_source.id)
|
||||||
|
|
||||||
|
# ── Step 4: color-strip source ───────────────────────────────────────
|
||||||
|
if data.calibration is not None:
|
||||||
|
try:
|
||||||
|
calibration = calibration_from_dict(data.calibration)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Invalid calibration dict: {exc}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
calibration = create_default_calibration(led_count)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
css_name = "Screen capture (scaffold)"
|
||||||
|
try:
|
||||||
|
css = css_store.create_source(
|
||||||
|
name=css_name,
|
||||||
|
source_type="picture",
|
||||||
|
picture_source_id=picture_source.id,
|
||||||
|
calibration=calibration,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
created_ids.append(("color_strip_source", css.id))
|
||||||
|
pending_events.append(("color_strip_source", css.id))
|
||||||
|
logger.info("Scaffold: created color-strip source %s", css.id)
|
||||||
|
|
||||||
|
# ── Step 5: LED output target ────────────────────────────────────────
|
||||||
|
target_name = "LED output (scaffold)"
|
||||||
|
try:
|
||||||
|
target = output_target_store.create_wled_target(
|
||||||
|
name=target_name,
|
||||||
|
device_id=device_id,
|
||||||
|
color_strip_source_id=css.id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
created_ids.append(("output_target", target.id))
|
||||||
|
pending_events.append(("output_target", target.id))
|
||||||
|
logger.info("Scaffold: created output target %s", target.id)
|
||||||
|
|
||||||
|
# ── Step 5b: register target with ProcessorManager ───────────────────
|
||||||
|
try:
|
||||||
|
target.register_with_manager(manager)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Scaffold: could not register target %s in processor manager: %s",
|
||||||
|
target.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
_rollback(created_ids, **rollback_stores)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Scaffold: unexpected error — rolling back: %s", exc, exc_info=True)
|
||||||
|
_rollback(created_ids, **rollback_stores)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error during scaffold")
|
||||||
|
|
||||||
|
# ── Full chain succeeded — fire all deferred "created" events ───────────
|
||||||
|
for entity_type, entity_id in pending_events:
|
||||||
|
fire_entity_event(entity_type, "created", entity_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Scaffold complete: device=%s tpl=%s ps=%s css=%s target=%s",
|
||||||
|
device_id,
|
||||||
|
capture_template_id,
|
||||||
|
picture_source.id,
|
||||||
|
css.id,
|
||||||
|
target.id,
|
||||||
|
)
|
||||||
|
return ScaffoldResponse(
|
||||||
|
device_id=device_id,
|
||||||
|
capture_template_id=capture_template_id,
|
||||||
|
picture_source_id=picture_source.id,
|
||||||
|
color_strip_source_id=css.id,
|
||||||
|
output_target_id=target.id,
|
||||||
|
capture_template_reused=template_reused,
|
||||||
|
)
|
||||||
@@ -30,7 +30,9 @@ from ledgrab.api.dependencies import (
|
|||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
|
get_playlist_engine,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
|
get_scene_playlist_store,
|
||||||
get_scene_preset_store,
|
get_scene_preset_store,
|
||||||
get_sync_clock_manager,
|
get_sync_clock_manager,
|
||||||
get_sync_clock_store,
|
get_sync_clock_store,
|
||||||
@@ -43,6 +45,7 @@ from ledgrab.utils import get_logger
|
|||||||
from .color_strip_sources.crud import list_color_strip_sources
|
from .color_strip_sources.crud import list_color_strip_sources
|
||||||
from .devices import list_devices, resolve_device_brightness
|
from .devices import list_devices, resolve_device_brightness
|
||||||
from .output_targets import batch_target_metrics, batch_target_states, list_targets
|
from .output_targets import batch_target_metrics, batch_target_states, list_targets
|
||||||
|
from .scene_playlists import list_scene_playlists
|
||||||
from .scene_presets import list_scene_presets
|
from .scene_presets import list_scene_presets
|
||||||
from .sync_clocks import list_sync_clocks
|
from .sync_clocks import list_sync_clocks
|
||||||
from .system import get_system_performance, health_check
|
from .system import get_system_performance, health_check
|
||||||
@@ -53,7 +56,9 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Selectable snapshot sections — these are exactly the response top-level keys.
|
# Selectable snapshot sections — these are exactly the response top-level keys,
|
||||||
|
# except ``scene_playlists`` which also emits a companion ``playlist_state`` key
|
||||||
|
# (the single global cycling state; see the handler).
|
||||||
SNAPSHOT_SECTIONS = (
|
SNAPSHOT_SECTIONS = (
|
||||||
"targets",
|
"targets",
|
||||||
"target_states",
|
"target_states",
|
||||||
@@ -63,6 +68,7 @@ SNAPSHOT_SECTIONS = (
|
|||||||
"css_sources",
|
"css_sources",
|
||||||
"value_sources",
|
"value_sources",
|
||||||
"scene_presets",
|
"scene_presets",
|
||||||
|
"scene_playlists",
|
||||||
"sync_clocks",
|
"sync_clocks",
|
||||||
"system",
|
"system",
|
||||||
)
|
)
|
||||||
@@ -135,6 +141,8 @@ async def get_snapshot(
|
|||||||
css_store=Depends(get_color_strip_store),
|
css_store=Depends(get_color_strip_store),
|
||||||
value_store=Depends(get_value_source_store),
|
value_store=Depends(get_value_source_store),
|
||||||
preset_store=Depends(get_scene_preset_store),
|
preset_store=Depends(get_scene_preset_store),
|
||||||
|
playlist_store=Depends(get_scene_playlist_store),
|
||||||
|
playlist_engine=Depends(get_playlist_engine),
|
||||||
clock_store=Depends(get_sync_clock_store),
|
clock_store=Depends(get_sync_clock_store),
|
||||||
clock_manager=Depends(get_sync_clock_manager),
|
clock_manager=Depends(get_sync_clock_manager),
|
||||||
update_service=Depends(get_update_service),
|
update_service=Depends(get_update_service),
|
||||||
@@ -152,6 +160,8 @@ async def get_snapshot(
|
|||||||
"css_sources": [...],
|
"css_sources": [...],
|
||||||
"value_sources": [...],
|
"value_sources": [...],
|
||||||
"scene_presets": [...],
|
"scene_presets": [...],
|
||||||
|
"scene_playlists": [...],
|
||||||
|
"playlist_state": {...}, # companion to scene_playlists
|
||||||
"sync_clocks": [...],
|
"sync_clocks": [...],
|
||||||
"system": {"performance": {...}, "health": {...}, "update": {...}}
|
"system": {"performance": {...}, "health": {...}, "update": {...}}
|
||||||
}
|
}
|
||||||
@@ -184,6 +194,14 @@ async def get_snapshot(
|
|||||||
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
|
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
|
||||||
if "scene_presets" in sections:
|
if "scene_presets" in sections:
|
||||||
result["scene_presets"] = (await list_scene_presets(_auth, preset_store)).presets
|
result["scene_presets"] = (await list_scene_presets(_auth, preset_store)).presets
|
||||||
|
if "scene_playlists" in sections:
|
||||||
|
# One call returns both the playlist list (each with ``is_running``) and
|
||||||
|
# the single global cycling state (current index / preset / dwell). The
|
||||||
|
# state is emitted as a companion top-level key because it describes the
|
||||||
|
# one running playlist, not any individual list entry.
|
||||||
|
playlists = await list_scene_playlists(_auth, playlist_store, playlist_engine)
|
||||||
|
result["scene_playlists"] = playlists.playlists
|
||||||
|
result["playlist_state"] = playlists.state
|
||||||
if "sync_clocks" in sections:
|
if "sync_clocks" in sections:
|
||||||
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
|
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
|
||||||
result["sync_clocks"] = clocks.clocks
|
result["sync_clocks"] = clocks.clocks
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ class RuleSchema(BaseModel):
|
|||||||
# Time-of-day rule fields
|
# Time-of-day rule fields
|
||||||
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
|
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
|
||||||
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
|
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
|
||||||
|
days_of_week: list[int] | None = Field(
|
||||||
|
None,
|
||||||
|
description="Active weekdays for time_of_day rule (0=Mon..6=Sun). Empty/null = every day.",
|
||||||
|
)
|
||||||
|
timezone: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="IANA timezone for time_of_day rule (e.g. 'Europe/Berlin'). Empty = server local.",
|
||||||
|
)
|
||||||
# System idle rule fields
|
# System idle rule fields
|
||||||
idle_minutes: int | None = Field(
|
idle_minutes: int | None = Field(
|
||||||
None, description="Idle timeout in minutes (for system_idle rule)"
|
None, description="Idle timeout in minutes (for system_idle rule)"
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Pydantic schemas for the calibration session and solver API."""
|
||||||
|
|
||||||
|
from typing import Annotated, List, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSessionStartRequest(BaseModel):
|
||||||
|
"""Request to start a calibration session on a device."""
|
||||||
|
|
||||||
|
device_id: str = Field(description="ID of the device to drive during calibration")
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSessionPositionRequest(BaseModel):
|
||||||
|
"""Request to advance the chase pixel to a specific LED index."""
|
||||||
|
|
||||||
|
index: int = Field(ge=0, description="LED index to illuminate (0-based)")
|
||||||
|
window: int = Field(
|
||||||
|
default=1,
|
||||||
|
ge=0,
|
||||||
|
le=10,
|
||||||
|
description="Number of dim neighbour LEDs to show on each side (0 = centre only)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSessionStateResponse(BaseModel):
|
||||||
|
"""Current calibration session state."""
|
||||||
|
|
||||||
|
active: bool = Field(description="Whether a session is currently active")
|
||||||
|
device_id: str | None = Field(None, description="Device being driven (null if inactive)")
|
||||||
|
led_count: int = Field(0, description="LED count of the active device")
|
||||||
|
prior_target_id: str | None = Field(
|
||||||
|
None, description="Target that was running before the session (will be restored on stop)"
|
||||||
|
)
|
||||||
|
last_activity: str | None = Field(
|
||||||
|
None, description="ISO timestamp of the last position call (null if inactive)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Solver ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSolveRequest(BaseModel):
|
||||||
|
"""Request to solve a CalibrationConfig from 4 corner tap indices.
|
||||||
|
|
||||||
|
Provide either *device_id* (the server derives led_count from the device)
|
||||||
|
or *led_count* directly. *device_id* takes precedence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
device_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
description=("Device ID to derive led_count from (preferred over led_count field)"),
|
||||||
|
)
|
||||||
|
led_count: int | None = Field(
|
||||||
|
None,
|
||||||
|
ge=1,
|
||||||
|
description="Total LED count (used when device_id is not provided)",
|
||||||
|
)
|
||||||
|
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
|
||||||
|
description="Starting corner of the strip"
|
||||||
|
)
|
||||||
|
layout: Literal["clockwise", "counterclockwise"] = Field(
|
||||||
|
description="Winding direction of the strip"
|
||||||
|
)
|
||||||
|
corner_indices: List[Annotated[int, Field(ge=0)]] = Field(
|
||||||
|
description=(
|
||||||
|
"Four strip indices — one per screen corner — in the strip-walk order "
|
||||||
|
"defined by (start_position, layout). Index 0 of the strip is the "
|
||||||
|
"start corner; the four tap positions are recorded in strip order "
|
||||||
|
"beginning from that start corner (the solver lays edges out from "
|
||||||
|
"led_start=0, so a non-zero physical start would require the `offset` "
|
||||||
|
"field rather than a shifted corner_indices[0]). Each element must be "
|
||||||
|
"non-negative (ge=0); out-of-range values yield a 422."
|
||||||
|
),
|
||||||
|
min_length=4,
|
||||||
|
max_length=4,
|
||||||
|
)
|
||||||
|
offset: int = Field(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
description="Physical LED offset (0 = no offset)",
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _require_device_or_led_count(self) -> "CalibrationSolveRequest":
|
||||||
|
if self.device_id is None and self.led_count is None:
|
||||||
|
raise ValueError("Either 'device_id' or 'led_count' must be provided")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSolvedResponse(BaseModel):
|
||||||
|
"""Solved calibration config in simple-mode dict form."""
|
||||||
|
|
||||||
|
mode: Literal["simple"] = "simple"
|
||||||
|
layout: str = Field(description="Winding direction")
|
||||||
|
start_position: str = Field(description="Starting corner")
|
||||||
|
leds_top: int = Field(ge=0, description="LEDs on the top edge")
|
||||||
|
leds_right: int = Field(ge=0, description="LEDs on the right edge")
|
||||||
|
leds_bottom: int = Field(ge=0, description="LEDs on the bottom edge")
|
||||||
|
leds_left: int = Field(ge=0, description="LEDs on the left edge")
|
||||||
|
offset: int = Field(ge=0, description="Physical LED offset")
|
||||||
@@ -344,6 +344,18 @@ class Calibration(BaseModel):
|
|||||||
border_width: int = Field(
|
border_width: int = Field(
|
||||||
default=10, ge=1, le=100, description="Border width in pixels for edge sampling"
|
default=10, ge=1, le=100, description="Border width in pixels for edge sampling"
|
||||||
)
|
)
|
||||||
|
roi_x: float = Field(
|
||||||
|
default=0.0, ge=0.0, le=1.0, description="ROI left edge as a fraction of width (0..1)"
|
||||||
|
)
|
||||||
|
roi_y: float = Field(
|
||||||
|
default=0.0, ge=0.0, le=1.0, description="ROI top edge as a fraction of height (0..1)"
|
||||||
|
)
|
||||||
|
roi_width: float = Field(
|
||||||
|
default=1.0, gt=0.0, le=1.0, description="ROI width as a fraction of width (0..1)"
|
||||||
|
)
|
||||||
|
roi_height: float = Field(
|
||||||
|
default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalibrationTestModeRequest(BaseModel):
|
class CalibrationTestModeRequest(BaseModel):
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
|
|||||||
adaptive_fps: bool = Field(
|
adaptive_fps: bool = Field(
|
||||||
default=False, description="Auto-reduce FPS when device is unresponsive"
|
default=False, description="Auto-reduce FPS when device is unresponsive"
|
||||||
)
|
)
|
||||||
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
|
protocol: str = Field(default="ddp", description="Send protocol (ddp, udp, or http)")
|
||||||
|
max_milliamps: int = Field(
|
||||||
|
default=0, description="ABL: PSU current budget in mA (0 = unlimited)"
|
||||||
|
)
|
||||||
|
milliamps_per_led: int = Field(default=55, description="ABL: full-white draw of one LED in mA")
|
||||||
|
|
||||||
|
|
||||||
class HALightOutputTargetResponse(_OutputTargetResponseBase):
|
class HALightOutputTargetResponse(_OutputTargetResponseBase):
|
||||||
@@ -233,8 +237,20 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
|
|||||||
)
|
)
|
||||||
protocol: str = Field(
|
protocol: str = Field(
|
||||||
default="ddp",
|
default="ddp",
|
||||||
pattern="^(ddp|http)$",
|
pattern="^(ddp|http|udp)$",
|
||||||
description="Send protocol: ddp (UDP) or http (JSON API)",
|
description="Send protocol: ddp (DDP/UDP), udp (WLED native realtime UDP), or http (JSON API)",
|
||||||
|
)
|
||||||
|
max_milliamps: int = Field(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
le=200000,
|
||||||
|
description="Automatic brightness limiting: PSU current budget in mA (0 = unlimited)",
|
||||||
|
)
|
||||||
|
milliamps_per_led: int = Field(
|
||||||
|
default=55,
|
||||||
|
ge=1,
|
||||||
|
le=200,
|
||||||
|
description="ABL: estimated full-white draw of a single LED, in mA",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -370,7 +386,15 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase):
|
|||||||
None, description="Auto-reduce FPS when device is unresponsive"
|
None, description="Auto-reduce FPS when device is unresponsive"
|
||||||
)
|
)
|
||||||
protocol: str | None = Field(
|
protocol: str | None = Field(
|
||||||
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
|
None,
|
||||||
|
pattern="^(ddp|http|udp)$",
|
||||||
|
description="Send protocol: ddp (DDP/UDP), udp (WLED native realtime UDP), or http (JSON API)",
|
||||||
|
)
|
||||||
|
max_milliamps: int | None = Field(
|
||||||
|
None, ge=0, le=200000, description="ABL: PSU current budget in mA (0 = unlimited)"
|
||||||
|
)
|
||||||
|
milliamps_per_led: int | None = Field(
|
||||||
|
None, ge=1, le=200, description="ABL: full-white draw of one LED in mA"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class PostprocessingTemplateResponse(BaseModel):
|
|||||||
max_length=32,
|
max_length=32,
|
||||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||||
)
|
)
|
||||||
|
is_builtin: bool = Field(default=False, description="True for read-only curated 'look' presets")
|
||||||
|
|
||||||
|
|
||||||
class PostprocessingTemplateListResponse(BaseModel):
|
class PostprocessingTemplateListResponse(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Scene playlist API schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ledgrab.storage.scene_playlist import (
|
||||||
|
MAX_DURATION_SECONDS,
|
||||||
|
MIN_DURATION_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistItemSchema(BaseModel):
|
||||||
|
scene_preset_id: str = Field(min_length=1, description="Referenced scene preset id")
|
||||||
|
duration_seconds: float = Field(
|
||||||
|
default=30.0,
|
||||||
|
ge=MIN_DURATION_SECONDS,
|
||||||
|
le=MAX_DURATION_SECONDS,
|
||||||
|
description="How long to hold this scene before advancing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistCreate(BaseModel):
|
||||||
|
"""Create a scene playlist."""
|
||||||
|
|
||||||
|
name: str = Field(description="Playlist name", min_length=1, max_length=100)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
items: List[PlaylistItemSchema] = Field(
|
||||||
|
default_factory=list, description="Ordered playlist items"
|
||||||
|
)
|
||||||
|
loop: bool = Field(default=True, description="Restart from the first item after the last")
|
||||||
|
shuffle: bool = Field(default=False, description="Randomise item order each cycle")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
icon: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=64,
|
||||||
|
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||||
|
)
|
||||||
|
icon_color: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=32,
|
||||||
|
description="Optional CSS color override for the icon.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistUpdate(BaseModel):
|
||||||
|
"""Update scene playlist metadata, items, and playback flags."""
|
||||||
|
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
description: str | None = Field(None, max_length=500)
|
||||||
|
items: List[PlaylistItemSchema] | None = Field(None, description="Replace the item list")
|
||||||
|
loop: bool | None = None
|
||||||
|
shuffle: bool | None = None
|
||||||
|
order: int | None = None
|
||||||
|
tags: List[str] | None = None
|
||||||
|
icon: str | None = Field(None, max_length=64)
|
||||||
|
icon_color: str | None = Field(None, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistRuntimeStateSchema(BaseModel):
|
||||||
|
is_running: bool = False
|
||||||
|
playlist_id: str | None = None
|
||||||
|
playlist_name: str | None = None
|
||||||
|
current_index: int = 0
|
||||||
|
item_count: int = 0
|
||||||
|
current_preset_id: str | None = None
|
||||||
|
started_at: datetime | None = None
|
||||||
|
step_started_at: datetime | None = None
|
||||||
|
step_duration: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistResponse(BaseModel):
|
||||||
|
"""Scene playlist with items and runtime running flag."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
items: List[PlaylistItemSchema]
|
||||||
|
loop: bool
|
||||||
|
shuffle: bool
|
||||||
|
order: int
|
||||||
|
tags: List[str] = Field(default_factory=list)
|
||||||
|
icon: str | None = Field(None, max_length=64)
|
||||||
|
icon_color: str | None = Field(None, max_length=32)
|
||||||
|
is_running: bool = Field(default=False, description="True if this playlist is cycling now")
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistListResponse(BaseModel):
|
||||||
|
playlists: List[ScenePlaylistResponse]
|
||||||
|
count: int
|
||||||
|
state: PlaylistRuntimeStateSchema
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Pydantic schemas for the setup scaffold endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ScaffoldRequest(BaseModel):
|
||||||
|
"""Request body for ``POST /api/v1/setup/scaffold``.
|
||||||
|
|
||||||
|
Creates a full capture-to-output chain:
|
||||||
|
capture template → picture source → picture color-strip source → LED output target
|
||||||
|
|
||||||
|
``device_id`` must reference an existing, validated device (created via
|
||||||
|
``POST /api/v1/devices``). The wizard flow is: discover/create the device
|
||||||
|
via the canonical device endpoint first, then call scaffold with the
|
||||||
|
resulting ``device_id``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Existing device (required) ────────────────────────────────────────────
|
||||||
|
device_id: str = Field(
|
||||||
|
description="ID of an existing device to wire into the chain.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Capture / picture source ──────────────────────────────────────────────
|
||||||
|
display_index: int = Field(
|
||||||
|
0,
|
||||||
|
ge=0,
|
||||||
|
le=63,
|
||||||
|
description="Index of the monitor to capture (0 = primary; max 63).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Optional calibration override ─────────────────────────────────────────
|
||||||
|
calibration: dict[str, Any] | None = Field(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"Optional CalibrationConfig dict to use for the color-strip source. "
|
||||||
|
"When omitted, ``create_default_calibration(led_count)`` is used."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScaffoldResponse(BaseModel):
|
||||||
|
"""IDs of every entity created (or reused) by the scaffold.
|
||||||
|
|
||||||
|
``capture_template_reused`` is ``True`` when the scaffold matched an
|
||||||
|
existing template by engine type instead of creating a new one.
|
||||||
|
The device is always pre-existing (created via the canonical device endpoint
|
||||||
|
before calling scaffold).
|
||||||
|
"""
|
||||||
|
|
||||||
|
device_id: str = Field(description="Device id (pre-existing).")
|
||||||
|
capture_template_id: str = Field(description="Capture template id.")
|
||||||
|
picture_source_id: str = Field(description="Raw picture source id.")
|
||||||
|
color_strip_source_id: str = Field(description="Picture color-strip source id.")
|
||||||
|
output_target_id: str = Field(description="LED output target id.")
|
||||||
|
|
||||||
|
capture_template_reused: bool = Field(
|
||||||
|
False,
|
||||||
|
description="True when an existing matching capture template was reused.",
|
||||||
|
)
|
||||||
@@ -26,6 +26,33 @@ from ledgrab.utils import get_logger
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Cache resolved IANA timezones (and remember invalid names) so the ~1 Hz
|
||||||
|
# automation tick neither re-parses tzdata nor log-spams on a bad name.
|
||||||
|
_TZ_CACHE: Dict[str, object] = {}
|
||||||
|
_TZ_WARNED: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_in_tz(tz_name: str) -> datetime:
|
||||||
|
"""Current local time, in ``tz_name`` (IANA) if given, else the server's."""
|
||||||
|
if not tz_name:
|
||||||
|
return datetime.now()
|
||||||
|
tz = _TZ_CACHE.get(tz_name)
|
||||||
|
if tz is None:
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
_TZ_CACHE[tz_name] = tz
|
||||||
|
except Exception:
|
||||||
|
if tz_name not in _TZ_WARNED:
|
||||||
|
_TZ_WARNED.add(tz_name)
|
||||||
|
logger.warning(
|
||||||
|
"Invalid timezone %r for time-of-day rule; using server local time",
|
||||||
|
tz_name,
|
||||||
|
)
|
||||||
|
return datetime.now()
|
||||||
|
return datetime.now(tz)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _RuleEvalContext:
|
class _RuleEvalContext:
|
||||||
@@ -519,16 +546,26 @@ class AutomationEngine:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
||||||
now = datetime.now()
|
now = _now_in_tz(rule.timezone)
|
||||||
current = now.hour * 60 + now.minute
|
current = now.hour * 60 + now.minute
|
||||||
parts_s = rule.start_time.split(":")
|
parts_s = rule.start_time.split(":")
|
||||||
parts_e = rule.end_time.split(":")
|
parts_e = rule.end_time.split(":")
|
||||||
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
||||||
end = int(parts_e[0]) * 60 + int(parts_e[1])
|
end = int(parts_e[0]) * 60 + int(parts_e[1])
|
||||||
|
days = rule.days_of_week
|
||||||
|
|
||||||
if start <= end:
|
if start <= end:
|
||||||
return start <= current <= end
|
if not (start <= current <= end):
|
||||||
# Overnight range (e.g. 22:00 → 06:00)
|
return False
|
||||||
return current >= start or current <= end
|
return not days or now.weekday() in days
|
||||||
|
|
||||||
|
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its
|
||||||
|
# START day, so the after-midnight tail is matched against yesterday.
|
||||||
|
if current >= start: # evening portion — today's window
|
||||||
|
return not days or now.weekday() in days
|
||||||
|
if current <= end: # early-morning portion — yesterday's window
|
||||||
|
return not days or ((now.weekday() - 1) % 7) in days
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
|
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ class CalibrationConfig:
|
|||||||
skip_leds_end: int = 0
|
skip_leds_end: int = 0
|
||||||
# Border width: how many pixels from the screen edge to sample
|
# Border width: how many pixels from the screen edge to sample
|
||||||
border_width: int = 10
|
border_width: int = 10
|
||||||
|
# Region of interest (simple mode): sample only this sub-rectangle of the
|
||||||
|
# frame (fractions 0..1). Defaults to the full frame. Lets a user exclude
|
||||||
|
# HUDs/taskbars/letterboxing from the sampled border colours.
|
||||||
|
roi_x: float = 0.0
|
||||||
|
roi_y: float = 0.0
|
||||||
|
roi_width: float = 1.0
|
||||||
|
roi_height: float = 1.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_roi(self) -> bool:
|
||||||
|
"""True when the ROI is narrower than the full frame."""
|
||||||
|
return self.roi_x > 0.0 or self.roi_y > 0.0 or self.roi_width < 1.0 or self.roi_height < 1.0
|
||||||
|
|
||||||
def build_segments(self) -> List[CalibrationSegment]:
|
def build_segments(self) -> List[CalibrationSegment]:
|
||||||
"""Derive segment list from core parameters."""
|
"""Derive segment list from core parameters."""
|
||||||
@@ -656,6 +668,98 @@ def create_pixel_mapper(
|
|||||||
return PixelMapper(calibration, interpolation_mode)
|
return PixelMapper(calibration, interpolation_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def solve_calibration(
|
||||||
|
led_count: int,
|
||||||
|
start_position: str,
|
||||||
|
layout: str,
|
||||||
|
corner_indices: List[int],
|
||||||
|
offset: int = 0,
|
||||||
|
) -> "CalibrationConfig":
|
||||||
|
"""Derive a CalibrationConfig from 4 corner tap indices.
|
||||||
|
|
||||||
|
Given the LED-strip indices where the user tapped each physical corner of
|
||||||
|
the screen (in strip-walk order matching *start_position* and *layout*),
|
||||||
|
compute per-edge LED counts that are consistent with
|
||||||
|
``EDGE_ORDER``/``EDGE_REVERSE`` and round-trip through
|
||||||
|
``build_segments()``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
led_count: Total number of LEDs on the strip.
|
||||||
|
start_position: Starting corner of the strip
|
||||||
|
(``"top_left"``, ``"top_right"``, ``"bottom_left"``,
|
||||||
|
``"bottom_right"``).
|
||||||
|
layout: Winding direction (``"clockwise"`` or
|
||||||
|
``"counterclockwise"``).
|
||||||
|
corner_indices: Four strip indices, one per screen corner, in the
|
||||||
|
same order as the strip walk defined by ``EDGE_ORDER`` for the
|
||||||
|
given *(start_position, layout)* pair. Index 0 is the start
|
||||||
|
corner, index 1 is the second corner reached while walking,
|
||||||
|
etc. Indices may wrap around (i.e. the last segment may
|
||||||
|
straddle the physical end of the strip).
|
||||||
|
offset: Physical LED offset stored directly on the config (0 = none).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``CalibrationConfig`` in simple mode with per-edge counts filled in.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If *start_position*, *layout*, or the number of
|
||||||
|
corner indices is invalid.
|
||||||
|
"""
|
||||||
|
key = (start_position, layout)
|
||||||
|
if key not in EDGE_ORDER:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid start_position/layout combination: {start_position!r}/{layout!r}"
|
||||||
|
)
|
||||||
|
if len(corner_indices) != 4:
|
||||||
|
raise ValueError(f"corner_indices must have exactly 4 entries, got {len(corner_indices)}")
|
||||||
|
if led_count <= 0:
|
||||||
|
raise ValueError(f"led_count must be positive, got {led_count}")
|
||||||
|
|
||||||
|
edge_order = EDGE_ORDER[key] # 4 edges in strip-walk order
|
||||||
|
|
||||||
|
# Compute per-edge LED counts from consecutive corner indices.
|
||||||
|
# The i-th edge spans from corner_indices[i] to corner_indices[(i+1) % 4],
|
||||||
|
# wrapping around led_count if necessary.
|
||||||
|
edge_counts: dict[str, int] = {}
|
||||||
|
for i, edge in enumerate(edge_order):
|
||||||
|
start_idx = corner_indices[i] % led_count
|
||||||
|
end_idx = corner_indices[(i + 1) % 4] % led_count
|
||||||
|
if end_idx > start_idx:
|
||||||
|
count = end_idx - start_idx
|
||||||
|
elif end_idx == start_idx:
|
||||||
|
# Adjacent taps on the same index → 0-LED edge
|
||||||
|
count = 0
|
||||||
|
else:
|
||||||
|
# Wrap-around: strip crosses the physical end
|
||||||
|
count = (led_count - start_idx) + end_idx
|
||||||
|
edge_counts[edge] = count
|
||||||
|
|
||||||
|
cfg = CalibrationConfig(
|
||||||
|
mode="simple",
|
||||||
|
layout=layout,
|
||||||
|
start_position=start_position,
|
||||||
|
leds_top=edge_counts.get("top", 0),
|
||||||
|
leds_right=edge_counts.get("right", 0),
|
||||||
|
leds_bottom=edge_counts.get("bottom", 0),
|
||||||
|
leds_left=edge_counts.get("left", 0),
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"solve_calibration: start=%s layout=%s corner_indices=%s "
|
||||||
|
"-> top=%d right=%d bottom=%d left=%d offset=%d",
|
||||||
|
start_position,
|
||||||
|
layout,
|
||||||
|
corner_indices,
|
||||||
|
cfg.leds_top,
|
||||||
|
cfg.leds_right,
|
||||||
|
cfg.leds_bottom,
|
||||||
|
cfg.leds_left,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def create_default_calibration(
|
def create_default_calibration(
|
||||||
led_count: int,
|
led_count: int,
|
||||||
aspect_width: int = 16,
|
aspect_width: int = 16,
|
||||||
@@ -720,6 +824,30 @@ def create_default_calibration(
|
|||||||
right_count = max(1, right_count)
|
right_count = max(1, right_count)
|
||||||
left_count = max(1, left_count)
|
left_count = max(1, left_count)
|
||||||
|
|
||||||
|
# The max(1, ...) floors above can push the total above led_count for
|
||||||
|
# small counts (e.g. led_count=5 -> top=2,right=1,bottom=2,left=1 = 6).
|
||||||
|
# Trim the largest edge that stays >= 1 until the total matches exactly.
|
||||||
|
edge_order = ["bottom", "top", "right", "left"]
|
||||||
|
counts = {
|
||||||
|
"bottom": bottom_count,
|
||||||
|
"top": top_count,
|
||||||
|
"right": right_count,
|
||||||
|
"left": left_count,
|
||||||
|
}
|
||||||
|
overshoot = sum(counts.values()) - led_count
|
||||||
|
while overshoot > 0:
|
||||||
|
# Pick the largest edge that can still be reduced (stays >= 1).
|
||||||
|
trimmable = [e for e in edge_order if counts[e] > 1]
|
||||||
|
if not trimmable:
|
||||||
|
break
|
||||||
|
target_edge = max(trimmable, key=lambda e: counts[e])
|
||||||
|
counts[target_edge] -= 1
|
||||||
|
overshoot -= 1
|
||||||
|
bottom_count = counts["bottom"]
|
||||||
|
top_count = counts["top"]
|
||||||
|
right_count = counts["right"]
|
||||||
|
left_count = counts["left"]
|
||||||
|
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
start_position="bottom_left",
|
start_position="bottom_left",
|
||||||
@@ -799,6 +927,10 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
|||||||
skip_leds_start=data.get("skip_leds_start", 0),
|
skip_leds_start=data.get("skip_leds_start", 0),
|
||||||
skip_leds_end=data.get("skip_leds_end", 0),
|
skip_leds_end=data.get("skip_leds_end", 0),
|
||||||
border_width=data.get("border_width", 10),
|
border_width=data.get("border_width", 10),
|
||||||
|
roi_x=data.get("roi_x", 0.0),
|
||||||
|
roi_y=data.get("roi_y", 0.0),
|
||||||
|
roi_width=data.get("roi_width", 1.0),
|
||||||
|
roi_height=data.get("roi_height", 1.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
config.validate()
|
config.validate()
|
||||||
@@ -870,4 +1002,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
result["skip_leds_end"] = config.skip_leds_end
|
result["skip_leds_end"] = config.skip_leds_end
|
||||||
if config.border_width != 10:
|
if config.border_width != 10:
|
||||||
result["border_width"] = config.border_width
|
result["border_width"] = config.border_width
|
||||||
|
# Include ROI only when it is not the full frame
|
||||||
|
if config.has_roi:
|
||||||
|
result["roi_x"] = config.roi_x
|
||||||
|
result["roi_y"] = config.roi_y
|
||||||
|
result["roi_width"] = config.roi_width
|
||||||
|
result["roi_height"] = config.roi_height
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"""Calibration session lifecycle and per-LED chase driver.
|
||||||
|
|
||||||
|
Provides two things:
|
||||||
|
1. ``set_calibration_pixel`` — direct per-index LED write for the chase
|
||||||
|
(added beside ``set_test_mode`` on ``ProcessorManager`` via the mixin, but
|
||||||
|
kept here to avoid growing device_test_mode.py further).
|
||||||
|
2. ``CalibrationSession`` — single-active-session guard with idle timeout and
|
||||||
|
guaranteed stop/restore contract.
|
||||||
|
|
||||||
|
Stop / restore contract (required by Phase 3 UI)
|
||||||
|
-------------------------------------------------
|
||||||
|
- ``start(device_id)``:
|
||||||
|
* If a target is currently processing on *device_id*, stop it and record
|
||||||
|
its ``target_id`` as ``_prior_target_id``.
|
||||||
|
* Send the device to black (chase start state).
|
||||||
|
* Record session as active with a fresh ``last_activity`` timestamp.
|
||||||
|
* Only one active session is allowed at a time; starting a new one on any
|
||||||
|
device while another is active calls ``stop()`` on the old one first.
|
||||||
|
- ``position(index, window)``:
|
||||||
|
* Validates ``index < led_count``; raises ``ValueError`` on out-of-range.
|
||||||
|
* Sends a chase pixel (bright white centre ±window dim neighbours).
|
||||||
|
* Updates ``last_activity``.
|
||||||
|
- ``stop()`` / ``cancel()``:
|
||||||
|
* Sends all-black to clear the device.
|
||||||
|
* If ``_prior_target_id`` was recorded, calls ``start_processing`` to
|
||||||
|
restart it.
|
||||||
|
* Clears the session state.
|
||||||
|
* NEVER leaves the device dark or stuck in chase.
|
||||||
|
- Idle timeout (``IDLE_TIMEOUT_SECONDS``, default 60 s):
|
||||||
|
* A background asyncio task checks ``last_activity``; if the session has
|
||||||
|
been idle longer than the timeout, ``stop()`` is called automatically.
|
||||||
|
* The timeout task is cancelled when ``stop()`` is called explicitly.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
- ``set_calibration_pixel`` reuses ``_get_idle_client`` /
|
||||||
|
``_send_pixels_to_device`` from ``DeviceTestModeMixin``; no new connection
|
||||||
|
management is needed.
|
||||||
|
- The session holds a reference to the ``ProcessorManager`` so it can call
|
||||||
|
``stop_processing`` / ``start_processing``.
|
||||||
|
- Thread-safety: all public methods are ``async``; the idle-timeout callback
|
||||||
|
schedules itself on the running event loop via ``asyncio.ensure_future``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
IDLE_TIMEOUT_SECONDS: int = 60
|
||||||
|
"""Auto-stop a calibration session after this many seconds of inactivity."""
|
||||||
|
|
||||||
|
_CHASE_CENTER_COLOR: tuple[int, int, int] = (255, 255, 255)
|
||||||
|
"""Bright white for the chase centre pixel."""
|
||||||
|
|
||||||
|
_CHASE_WING_COLOR: tuple[int, int, int] = (60, 60, 60)
|
||||||
|
"""Dim grey for ±window neighbour pixels."""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mixin: per-index chase driver ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationChaseMixin:
|
||||||
|
"""Adds ``set_calibration_pixel`` to ``ProcessorManager``.
|
||||||
|
|
||||||
|
Requires the same host-class attributes as ``DeviceTestModeMixin``:
|
||||||
|
``_devices``, ``_processors``, ``_idle_clients``.
|
||||||
|
Inherits ``_send_pixels_to_device`` and ``_get_idle_client`` from
|
||||||
|
``DeviceTestModeMixin`` (both already on ``ProcessorManager``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def set_calibration_pixel(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
index: int,
|
||||||
|
color: tuple[int, int, int] = _CHASE_CENTER_COLOR,
|
||||||
|
window: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Light a single LED index (plus optional ±window neighbours) on a device.
|
||||||
|
|
||||||
|
Sends a full pixel array to avoid partial-frame artefacts. The centre
|
||||||
|
LED is set to *color*; the ``window`` neighbours on each side are set to
|
||||||
|
``_CHASE_WING_COLOR`` (dim grey) so the user can see which direction the
|
||||||
|
strip is wound.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Target device ID (must be registered).
|
||||||
|
index: LED index to light (0-based). Must be < ``led_count``.
|
||||||
|
color: RGB tuple for the centre LED (default bright white).
|
||||||
|
window: Number of neighbouring LEDs to dim on each side (default 1).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If *device_id* is not registered or *index* is out of
|
||||||
|
range.
|
||||||
|
"""
|
||||||
|
if device_id not in self._devices:
|
||||||
|
raise ValueError(f"Device {device_id!r} not found")
|
||||||
|
ds = self._devices[device_id]
|
||||||
|
led_count = ds.led_count
|
||||||
|
if led_count <= 0:
|
||||||
|
raise ValueError(f"Device {device_id!r} has led_count={led_count}")
|
||||||
|
if not (0 <= index < led_count):
|
||||||
|
raise ValueError(
|
||||||
|
f"index {index} out of range for device {device_id!r} " f"(led_count={led_count})"
|
||||||
|
)
|
||||||
|
|
||||||
|
pixels: list[tuple[int, int, int]] = [(0, 0, 0)] * led_count
|
||||||
|
pixels[index] = color
|
||||||
|
for offset in range(1, window + 1):
|
||||||
|
left = (index - offset) % led_count
|
||||||
|
right = (index + offset) % led_count
|
||||||
|
pixels[left] = _CHASE_WING_COLOR
|
||||||
|
pixels[right] = _CHASE_WING_COLOR
|
||||||
|
# Re-assign center last so on tiny strips (window >= led_count) the
|
||||||
|
# center LED always shows the full color rather than a wrapped wing.
|
||||||
|
pixels[index] = color
|
||||||
|
|
||||||
|
await self._send_pixels_to_device(device_id, pixels)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"set_calibration_pixel: device=%s index=%d window=%d",
|
||||||
|
device_id,
|
||||||
|
index,
|
||||||
|
window,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSession:
|
||||||
|
"""Single-active calibration session with idle-timeout and stop/restore.
|
||||||
|
|
||||||
|
One instance is shared per application (singleton held by the API layer).
|
||||||
|
Only one session can be active at a time; starting a new session
|
||||||
|
automatically terminates the previous one.
|
||||||
|
|
||||||
|
All public methods that mutate session state acquire ``_lock`` so that
|
||||||
|
concurrent ``POST /session`` calls (or a ``stop`` racing with the idle
|
||||||
|
watchdog) cannot interleave and leave ``_prior_target_id`` stale. The
|
||||||
|
watchdog calls the internal ``_teardown_locked`` helper which must only be
|
||||||
|
invoked when the lock is already held; if the lock is already taken the
|
||||||
|
watchdog simply exits, letting the holder finish teardown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._manager: "ProcessorManager | None" = None
|
||||||
|
self._device_id: str | None = None
|
||||||
|
self._led_count: int = 0
|
||||||
|
self._prior_target_id: str | None = None
|
||||||
|
self._last_activity: datetime | None = None
|
||||||
|
self._timeout_task: asyncio.Task | None = None
|
||||||
|
self._active: bool = False
|
||||||
|
self._lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self._active
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_id(self) -> str | None:
|
||||||
|
return self._device_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def led_count(self) -> int:
|
||||||
|
return self._led_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_activity(self) -> datetime | None:
|
||||||
|
return self._last_activity
|
||||||
|
|
||||||
|
async def start(self, device_id: str, manager: "ProcessorManager") -> None:
|
||||||
|
"""Begin a calibration session on *device_id*.
|
||||||
|
|
||||||
|
If a session is already active (even on a different device), it is
|
||||||
|
stopped first. If a target is currently processing on *device_id*, it
|
||||||
|
is stopped and remembered so it can be restored when this session ends.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: The device to drive during calibration.
|
||||||
|
manager: Live ``ProcessorManager`` instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If *device_id* is not registered.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
# Validate device before touching any state or awaiting
|
||||||
|
if device_id not in manager._devices:
|
||||||
|
raise ValueError(f"Device {device_id!r} not found")
|
||||||
|
|
||||||
|
ds = manager._devices[device_id]
|
||||||
|
led_count = ds.led_count
|
||||||
|
|
||||||
|
# Capture the prior running target NOW — before any await — so the
|
||||||
|
# value cannot be mutated by a concurrent call that sneaks in after
|
||||||
|
# the lock is released between awaits.
|
||||||
|
prior_target_id = manager.get_processing_target_for_device(device_id)
|
||||||
|
|
||||||
|
# Terminate any existing session while we still hold the lock.
|
||||||
|
# Call _teardown_locked directly (we already hold the lock).
|
||||||
|
if self._active:
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.start: stopping existing session on device=%s "
|
||||||
|
"to start new one on device=%s",
|
||||||
|
self._device_id,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
await self._teardown_locked(cancelled=False)
|
||||||
|
|
||||||
|
# Stop any running target on this device and remember it for restore
|
||||||
|
if prior_target_id is not None:
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.start: stopping target %s on device %s for calibration",
|
||||||
|
prior_target_id,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
await manager.stop_processing(prior_target_id)
|
||||||
|
|
||||||
|
self._manager = manager
|
||||||
|
self._device_id = device_id
|
||||||
|
self._led_count = led_count
|
||||||
|
self._prior_target_id = prior_target_id
|
||||||
|
self._last_activity = datetime.now(timezone.utc)
|
||||||
|
self._active = True
|
||||||
|
|
||||||
|
# Clear the device to black so the chase starts from a clean state
|
||||||
|
await manager.send_clear_pixels(device_id)
|
||||||
|
|
||||||
|
# Start idle-timeout watchdog
|
||||||
|
self._timeout_task = asyncio.ensure_future(self._idle_watchdog())
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.start: session started on device=%s led_count=%d "
|
||||||
|
"prior_target=%s",
|
||||||
|
device_id,
|
||||||
|
led_count,
|
||||||
|
prior_target_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def position(self, index: int, window: int = 1) -> None:
|
||||||
|
"""Drive the chase pixel to *index* on the active device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: LED index to illuminate (0-based, must be < led_count).
|
||||||
|
window: Number of dim neighbours on each side (default 1).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If no session is active.
|
||||||
|
ValueError: If *index* is out of range.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if not self._active or self._manager is None or self._device_id is None:
|
||||||
|
raise RuntimeError("No active calibration session")
|
||||||
|
if not (0 <= index < self._led_count):
|
||||||
|
raise ValueError(f"index {index} out of range (led_count={self._led_count})")
|
||||||
|
|
||||||
|
self._last_activity = datetime.now(timezone.utc)
|
||||||
|
await self._manager.set_calibration_pixel(self._device_id, index, window=window)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"CalibrationSession.position: device=%s index=%d window=%d",
|
||||||
|
self._device_id,
|
||||||
|
index,
|
||||||
|
window,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""End the session: clear the device and restore the prior target.
|
||||||
|
|
||||||
|
Safe to call even if no session is active (no-op).
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
await self._teardown_locked(cancelled=False)
|
||||||
|
|
||||||
|
async def cancel(self) -> None:
|
||||||
|
"""Alias for ``stop()`` — ends the session without applying calibration."""
|
||||||
|
async with self._lock:
|
||||||
|
await self._teardown_locked(cancelled=True)
|
||||||
|
|
||||||
|
def get_state(self) -> dict:
|
||||||
|
"""Return a snapshot of the current session state for API responses."""
|
||||||
|
return {
|
||||||
|
"active": self._active,
|
||||||
|
"device_id": self._device_id,
|
||||||
|
"led_count": self._led_count,
|
||||||
|
"prior_target_id": self._prior_target_id,
|
||||||
|
"last_activity": (self._last_activity.isoformat() if self._last_activity else None),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Internal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _teardown_locked(self, cancelled: bool) -> None:
|
||||||
|
"""Clear the device, restore the prior target, and reset state.
|
||||||
|
|
||||||
|
MUST be called with ``self._lock`` already held by the caller.
|
||||||
|
Safe to call when already inactive (no-op).
|
||||||
|
"""
|
||||||
|
if not self._active:
|
||||||
|
return
|
||||||
|
|
||||||
|
device_id = self._device_id
|
||||||
|
manager = self._manager
|
||||||
|
prior_target_id = self._prior_target_id
|
||||||
|
|
||||||
|
# Cancel the idle watchdog — but only if we are NOT running inside it.
|
||||||
|
# Awaiting the current task would deadlock.
|
||||||
|
if (
|
||||||
|
self._timeout_task is not None
|
||||||
|
and self._timeout_task is not asyncio.current_task()
|
||||||
|
and not self._timeout_task.done()
|
||||||
|
):
|
||||||
|
self._timeout_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._timeout_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._timeout_task = None
|
||||||
|
|
||||||
|
# Reset state before side-effects so re-entrant calls are no-ops
|
||||||
|
self._active = False
|
||||||
|
self._device_id = None
|
||||||
|
self._led_count = 0
|
||||||
|
self._prior_target_id = None
|
||||||
|
self._last_activity = None
|
||||||
|
self._manager = None
|
||||||
|
|
||||||
|
if manager is None or device_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Clear the device to black
|
||||||
|
try:
|
||||||
|
await manager.send_clear_pixels(device_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"CalibrationSession._teardown: failed to clear pixels on %s: %s",
|
||||||
|
device_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Restore the prior target (if any)
|
||||||
|
if prior_target_id is not None:
|
||||||
|
try:
|
||||||
|
await manager.start_processing(prior_target_id)
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession._teardown: restored target %s on device %s",
|
||||||
|
prior_target_id,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"CalibrationSession._teardown: failed to restore target %s on " "device %s: %s",
|
||||||
|
prior_target_id,
|
||||||
|
device_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
action = "cancel" if cancelled else "stop"
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.%s: session ended on device=%s prior_target=%s",
|
||||||
|
action,
|
||||||
|
device_id,
|
||||||
|
prior_target_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _idle_watchdog(self) -> None:
|
||||||
|
"""Background task: auto-stop the session after IDLE_TIMEOUT_SECONDS.
|
||||||
|
|
||||||
|
Tries to acquire ``_lock`` when the timeout fires. If the lock is
|
||||||
|
already held (e.g. a concurrent ``stop()`` is in progress) the
|
||||||
|
``acquire`` will wait; once it gets the lock, ``_teardown_locked``
|
||||||
|
is a no-op if the session was already ended by the other caller.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
if not self._active or self._last_activity is None:
|
||||||
|
break
|
||||||
|
elapsed = (datetime.now(timezone.utc) - self._last_activity).total_seconds()
|
||||||
|
if elapsed >= IDLE_TIMEOUT_SECONDS:
|
||||||
|
logger.warning(
|
||||||
|
"CalibrationSession._idle_watchdog: session on device=%s "
|
||||||
|
"idle for %.0fs — auto-stopping",
|
||||||
|
self._device_id,
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
async with self._lock:
|
||||||
|
await self._teardown_locked(cancelled=False)
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module-level singleton ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_session: CalibrationSession = CalibrationSession()
|
||||||
|
|
||||||
|
|
||||||
|
def get_calibration_session() -> CalibrationSession:
|
||||||
|
"""Return the module-level singleton ``CalibrationSession``."""
|
||||||
|
return _session
|
||||||
@@ -159,6 +159,37 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
|
|||||||
raise RuntimeError(f"Screen capture failed: {e}")
|
raise RuntimeError(f"Screen capture failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def crop_screen_capture(
|
||||||
|
sc: ScreenCapture,
|
||||||
|
roi_x: float,
|
||||||
|
roi_y: float,
|
||||||
|
roi_width: float,
|
||||||
|
roi_height: float,
|
||||||
|
) -> ScreenCapture:
|
||||||
|
"""Crop a capture to a relative region-of-interest rectangle (fractions 0..1).
|
||||||
|
|
||||||
|
Sampling only a sub-rectangle of the frame lets a user exclude HUDs, task
|
||||||
|
bars, or letterboxing so they don't pollute the border colours. Returns the
|
||||||
|
original capture unchanged for a full-frame ROI (fast path). The cropped
|
||||||
|
image is a numpy view (no copy); out-of-range/degenerate ROIs are clamped so
|
||||||
|
at least a 1x1 region remains.
|
||||||
|
"""
|
||||||
|
if roi_x <= 0.0 and roi_y <= 0.0 and roi_width >= 1.0 and roi_height >= 1.0:
|
||||||
|
return sc
|
||||||
|
h, w = sc.image.shape[:2]
|
||||||
|
x0 = max(0, min(w - 1, int(round(roi_x * w))))
|
||||||
|
y0 = max(0, min(h - 1, int(round(roi_y * h))))
|
||||||
|
x1 = max(x0 + 1, min(w, int(round((roi_x + roi_width) * w))))
|
||||||
|
y1 = max(y0 + 1, min(h, int(round((roi_y + roi_height) * h))))
|
||||||
|
cropped = sc.image[y0:y1, x0:x1]
|
||||||
|
return ScreenCapture(
|
||||||
|
image=cropped,
|
||||||
|
width=x1 - x0,
|
||||||
|
height=y1 - y0,
|
||||||
|
display_index=sc.display_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10) -> BorderPixels:
|
def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10) -> BorderPixels:
|
||||||
"""Extract border pixels from screen capture.
|
"""Extract border pixels from screen capture.
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,15 @@ class DDPClient:
|
|||||||
all buses (observed in multi-bus setups). We reorder pixel channels
|
all buses (observed in multi-bus setups). We reorder pixel channels
|
||||||
here so the hardware receives the correct byte order directly.
|
here so the hardware receives the correct byte order directly.
|
||||||
|
|
||||||
|
TODO(ddp-multibus): currently UNUSED — ``send_pixels_numpy`` (the hot
|
||||||
|
send path) does NOT call this, so the per-bus color-order config
|
||||||
|
captured by ``set_buses`` is never applied to outgoing pixels. This
|
||||||
|
is intentionally left in place (not deleted) because it encodes real
|
||||||
|
multi-bus handling: if a multi-bus WLED setup needs per-bus byte
|
||||||
|
reordering, wire this into ``send_pixels_numpy`` before the payload
|
||||||
|
view is built (note it allocates a copy, so only call it when
|
||||||
|
``self._buses`` actually requires reordering).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pixel_array: (N, 3) uint8 numpy array in RGB order
|
pixel_array: (N, 3) uint8 numpy array in RGB order
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ class BaseDeviceConfig:
|
|||||||
class WLEDConfig(BaseDeviceConfig):
|
class WLEDConfig(BaseDeviceConfig):
|
||||||
device_type: Literal["wled"] = "wled"
|
device_type: Literal["wled"] = "wled"
|
||||||
use_ddp: bool = False
|
use_ddp: bool = False
|
||||||
|
# WLED native realtime UDP (port 21324) — mutually exclusive with use_ddp.
|
||||||
|
# realtime_timeout = seconds WLED stays in realtime after the last packet
|
||||||
|
# before reverting to its normal effect/preset (graceful auto-revert).
|
||||||
|
use_realtime: bool = False
|
||||||
|
realtime_timeout: int = 2
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ class WLEDClient(LEDClient):
|
|||||||
retry_attempts: int = 3,
|
retry_attempts: int = 3,
|
||||||
retry_delay: int = 1,
|
retry_delay: int = 1,
|
||||||
use_ddp: bool = False,
|
use_ddp: bool = False,
|
||||||
|
use_realtime: bool = False,
|
||||||
|
realtime_timeout: int = 2,
|
||||||
):
|
):
|
||||||
"""Initialize WLED client.
|
"""Initialize WLED client.
|
||||||
|
|
||||||
@@ -95,12 +97,17 @@ class WLEDClient(LEDClient):
|
|||||||
retry_attempts: Number of retry attempts on failure
|
retry_attempts: Number of retry attempts on failure
|
||||||
retry_delay: Delay between retries in seconds
|
retry_delay: Delay between retries in seconds
|
||||||
use_ddp: Force DDP protocol (auto-enabled for >500 LEDs)
|
use_ddp: Force DDP protocol (auto-enabled for >500 LEDs)
|
||||||
|
use_realtime: Use WLED native realtime UDP (port 21324) instead of DDP
|
||||||
|
realtime_timeout: Seconds WLED stays in realtime after the last packet
|
||||||
|
before reverting to its normal effect/preset (1-255)
|
||||||
"""
|
"""
|
||||||
self.url = url.rstrip("/")
|
self.url = url.rstrip("/")
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.retry_attempts = retry_attempts
|
self.retry_attempts = retry_attempts
|
||||||
self.retry_delay = retry_delay
|
self.retry_delay = retry_delay
|
||||||
self.use_ddp = use_ddp
|
self.use_ddp = use_ddp
|
||||||
|
self.use_realtime = use_realtime
|
||||||
|
self.realtime_timeout = realtime_timeout
|
||||||
|
|
||||||
# Extract hostname/IP from URL for DDP
|
# Extract hostname/IP from URL for DDP
|
||||||
parsed = urlparse(self.url)
|
parsed = urlparse(self.url)
|
||||||
@@ -108,6 +115,7 @@ class WLEDClient(LEDClient):
|
|||||||
|
|
||||||
self._client: httpx.AsyncClient | None = None
|
self._client: httpx.AsyncClient | None = None
|
||||||
self._ddp_client: DDPClient | None = None
|
self._ddp_client: DDPClient | None = None
|
||||||
|
self._realtime_client = None # WledRealtimeClient when use_realtime
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._pre_connect_state: dict | None = None
|
self._pre_connect_state: dict | None = None
|
||||||
|
|
||||||
@@ -127,8 +135,9 @@ class WLEDClient(LEDClient):
|
|||||||
# Test connection by getting device info
|
# Test connection by getting device info
|
||||||
info = await self.get_info()
|
info = await self.get_info()
|
||||||
|
|
||||||
# Auto-enable DDP for large LED counts
|
# Auto-enable DDP for large LED counts (unless the user explicitly
|
||||||
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp:
|
# chose native realtime UDP, which handles any size via DNRGB).
|
||||||
|
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp and not self.use_realtime:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), "
|
f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), "
|
||||||
"auto-enabling DDP protocol"
|
"auto-enabling DDP protocol"
|
||||||
@@ -138,8 +147,30 @@ class WLEDClient(LEDClient):
|
|||||||
# Snapshot device state BEFORE any mutations (for auto-restore)
|
# Snapshot device state BEFORE any mutations (for auto-restore)
|
||||||
self._pre_connect_state = await self.snapshot_device_state()
|
self._pre_connect_state = await self.snapshot_device_state()
|
||||||
|
|
||||||
|
# Create WLED native realtime UDP client if selected
|
||||||
|
if self.use_realtime:
|
||||||
|
from ledgrab.core.devices.wled_realtime_client import WledRealtimeClient
|
||||||
|
|
||||||
|
self._realtime_client = WledRealtimeClient(
|
||||||
|
self.host, rgbw=info.rgbw, timeout_secs=self.realtime_timeout
|
||||||
|
)
|
||||||
|
await self._realtime_client.connect()
|
||||||
|
try:
|
||||||
|
await self._request(
|
||||||
|
"POST",
|
||||||
|
"/json/state",
|
||||||
|
json_data={"on": True, "lor": 0, "AudioReactive": {"on": False}},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not configure device for realtime UDP: {e}")
|
||||||
|
logger.info(
|
||||||
|
"WLED native realtime UDP enabled (port 21324, %ds timeout, %s)",
|
||||||
|
self.realtime_timeout,
|
||||||
|
"RGBW" if info.rgbw else "RGB",
|
||||||
|
)
|
||||||
|
|
||||||
# Create DDP client if needed
|
# Create DDP client if needed
|
||||||
if self.use_ddp:
|
elif self.use_ddp:
|
||||||
self._ddp_client = DDPClient(self.host, rgbw=False)
|
self._ddp_client = DDPClient(self.host, rgbw=False)
|
||||||
# Pass per-bus config so DDP client can apply per-bus color reordering
|
# Pass per-bus config so DDP client can apply per-bus color reordering
|
||||||
if info.buses:
|
if info.buses:
|
||||||
@@ -191,6 +222,9 @@ class WLEDClient(LEDClient):
|
|||||||
if self._ddp_client:
|
if self._ddp_client:
|
||||||
await self._ddp_client.close()
|
await self._ddp_client.close()
|
||||||
self._ddp_client = None
|
self._ddp_client = None
|
||||||
|
if self._realtime_client:
|
||||||
|
await self._realtime_client.close()
|
||||||
|
self._realtime_client = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
logger.debug(f"Closed connection to {self.url}")
|
logger.debug(f"Closed connection to {self.url}")
|
||||||
|
|
||||||
@@ -201,8 +235,10 @@ class WLEDClient(LEDClient):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_fast_send(self) -> bool:
|
def supports_fast_send(self) -> bool:
|
||||||
"""True when DDP is active and ready for fire-and-forget sends."""
|
"""True when DDP or native realtime UDP is active (fire-and-forget)."""
|
||||||
return self.use_ddp and self._ddp_client is not None
|
return (self.use_ddp and self._ddp_client is not None) or (
|
||||||
|
self.use_realtime and self._realtime_client is not None
|
||||||
|
)
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
@@ -384,7 +420,10 @@ class WLEDClient(LEDClient):
|
|||||||
raise ValueError(f"Invalid RGB values at index {idx}: {tuple(pixel_arr[idx])}")
|
raise ValueError(f"Invalid RGB values at index {idx}: {tuple(pixel_arr[idx])}")
|
||||||
validated_pixels = pixel_arr.astype(np.uint8) if pixel_arr.dtype != np.uint8 else pixel_arr
|
validated_pixels = pixel_arr.astype(np.uint8) if pixel_arr.dtype != np.uint8 else pixel_arr
|
||||||
|
|
||||||
# Use DDP protocol if enabled
|
# Native realtime UDP takes precedence, then DDP, then HTTP
|
||||||
|
if self.use_realtime and self._realtime_client:
|
||||||
|
self._realtime_client.send_pixels_numpy(validated_pixels)
|
||||||
|
return True
|
||||||
if self.use_ddp and self._ddp_client:
|
if self.use_ddp and self._ddp_client:
|
||||||
return await self._send_pixels_ddp(validated_pixels, brightness)
|
return await self._send_pixels_ddp(validated_pixels, brightness)
|
||||||
else:
|
else:
|
||||||
@@ -485,8 +524,10 @@ class WLEDClient(LEDClient):
|
|||||||
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
|
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
|
||||||
brightness: Global brightness (0-255)
|
brightness: Global brightness (0-255)
|
||||||
"""
|
"""
|
||||||
if not self.use_ddp or not self._ddp_client:
|
if not (self.use_ddp and self._ddp_client) and not (
|
||||||
raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP")
|
self.use_realtime and self._realtime_client
|
||||||
|
):
|
||||||
|
raise RuntimeError("send_pixels_fast requires DDP or realtime UDP; use send_pixels")
|
||||||
|
|
||||||
if isinstance(pixels, np.ndarray):
|
if isinstance(pixels, np.ndarray):
|
||||||
pixel_array = pixels
|
pixel_array = pixels
|
||||||
@@ -494,7 +535,10 @@ class WLEDClient(LEDClient):
|
|||||||
pixel_array = np.array(pixels, dtype=np.uint8)
|
pixel_array = np.array(pixels, dtype=np.uint8)
|
||||||
|
|
||||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||||
self._ddp_client.send_pixels_numpy(pixel_array)
|
if self.use_realtime and self._realtime_client:
|
||||||
|
self._realtime_client.send_pixels_numpy(pixel_array)
|
||||||
|
else:
|
||||||
|
self._ddp_client.send_pixels_numpy(pixel_array)
|
||||||
|
|
||||||
# ===== LEDClient abstraction methods =====
|
# ===== LEDClient abstraction methods =====
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
|||||||
return WLEDClient(
|
return WLEDClient(
|
||||||
config.device_url,
|
config.device_url,
|
||||||
use_ddp=config.use_ddp,
|
use_ddp=config.use_ddp,
|
||||||
|
use_realtime=config.use_realtime,
|
||||||
|
realtime_timeout=config.realtime_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""WLED native realtime UDP client (port 21324).
|
||||||
|
|
||||||
|
WLED exposes a family of "realtime" UDP protocols separate from DDP. Compared to
|
||||||
|
the DDP path this gives three user-visible wins for the device LedGrab drives
|
||||||
|
most:
|
||||||
|
|
||||||
|
* **Auto-revert** — every packet carries a *timeout* byte. If LedGrab stops
|
||||||
|
streaming (host hiccup, sleep, crash), WLED returns to its normal effect /
|
||||||
|
preset after that many seconds instead of freezing on the last frame.
|
||||||
|
* **Correct RGBW whites** — the DRGBW variant carries an explicit white channel,
|
||||||
|
so RGBW strips are driven correctly instead of leaving W uncontrolled.
|
||||||
|
* **Lighter on weak Wi-Fi** — raw RGB with a 2-byte header, no DDP framing.
|
||||||
|
|
||||||
|
Unlike the DDP path, WLED applies the configured per-bus color order itself in
|
||||||
|
realtime mode, so this sender transmits plain RGB (no manual reordering) — the
|
||||||
|
user's WLED colour-order setting just works.
|
||||||
|
|
||||||
|
Packet layout (first byte selects the protocol)::
|
||||||
|
|
||||||
|
DRGB (2): [2][timeout] + R G B per LED (<= 490 LEDs)
|
||||||
|
DRGBW (3): [3][timeout] + R G B W per LED (<= 367 LEDs)
|
||||||
|
DNRGB (4): [4][timeout][start_hi][start_lo] + R G B per LED (chunked, 489/pkt)
|
||||||
|
|
||||||
|
The ``timeout`` byte is in **seconds** (1-255). DNRGB carries a 16-bit start
|
||||||
|
index so strips larger than one packet are sent as several chunks.
|
||||||
|
|
||||||
|
Ref: https://kno.wled.ge/interfaces/udp-realtime/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
REALTIME_PORT = 21324
|
||||||
|
|
||||||
|
# Protocol selector (first byte).
|
||||||
|
_DRGB = 2
|
||||||
|
_DRGBW = 3
|
||||||
|
_DNRGB = 4
|
||||||
|
|
||||||
|
# Per-protocol LED capacity (bounded by the ~1500-byte UDP payload).
|
||||||
|
_MAX_DRGB = 490 # 2 + 490*3 = 1472
|
||||||
|
_MAX_DRGBW = 367 # 2 + 367*4 = 1470
|
||||||
|
_MAX_DNRGB_CHUNK = 489 # 4 + 489*3 = 1471
|
||||||
|
|
||||||
|
# Default seconds WLED stays in realtime after the last packet before reverting.
|
||||||
|
DEFAULT_REALTIME_TIMEOUT = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_timeout(seconds: int) -> int:
|
||||||
|
"""Clamp the realtime timeout to the on-wire 1-255 range."""
|
||||||
|
return max(1, min(255, int(seconds)))
|
||||||
|
|
||||||
|
|
||||||
|
class WledRealtimeClient:
|
||||||
|
"""Fire-and-forget UDP sender for WLED native realtime protocols."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = REALTIME_PORT,
|
||||||
|
rgbw: bool = False,
|
||||||
|
timeout_secs: int = DEFAULT_REALTIME_TIMEOUT,
|
||||||
|
) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.rgbw = rgbw
|
||||||
|
self.timeout_secs = _clamp_timeout(timeout_secs)
|
||||||
|
self._transport: asyncio.DatagramTransport | None = None
|
||||||
|
self._protocol: asyncio.DatagramProtocol | None = None
|
||||||
|
# Reusable RGBW scratch (resized on demand) so the hot path doesn't
|
||||||
|
# allocate a fresh (N, 4) array per frame.
|
||||||
|
self._rgbw_buf: np.ndarray | None = None
|
||||||
|
self._rgbw_buf_n: int = 0
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Open the UDP datagram endpoint to the device."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._transport, self._protocol = await loop.create_datagram_endpoint(
|
||||||
|
asyncio.DatagramProtocol, remote_addr=(self.host, self.port)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"WLED realtime client connected to %s:%d (timeout %ds, %s)",
|
||||||
|
self.host,
|
||||||
|
self.port,
|
||||||
|
self.timeout_secs,
|
||||||
|
"RGBW" if self.rgbw else "RGB",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the datagram endpoint."""
|
||||||
|
if self._transport is not None:
|
||||||
|
self._transport.close()
|
||||||
|
self._transport = None
|
||||||
|
self._protocol = None
|
||||||
|
logger.debug("Closed WLED realtime connection to %s:%d", self.host, self.port)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._transport is not None
|
||||||
|
|
||||||
|
def _ensure_rgbw_buf(self, n: int) -> np.ndarray:
|
||||||
|
"""Return an ``(n, 4)`` uint8 RGBW buffer with the white channel zeroed."""
|
||||||
|
if self._rgbw_buf is None or self._rgbw_buf_n != n:
|
||||||
|
self._rgbw_buf = np.zeros((n, 4), dtype=np.uint8)
|
||||||
|
self._rgbw_buf_n = n
|
||||||
|
return self._rgbw_buf
|
||||||
|
|
||||||
|
def build_packets(self, pixels: np.ndarray) -> list[bytes]:
|
||||||
|
"""Build the realtime UDP packet(s) for one ``(N, 3)`` uint8 RGB frame.
|
||||||
|
|
||||||
|
Exposed (and pure) for unit testing the wire format. Picks DRGBW for
|
||||||
|
RGBW strips within range, DRGB for small RGB strips, otherwise DNRGB
|
||||||
|
chunks. The white channel is sent as 0 (colour comes from the RGB LEDs).
|
||||||
|
"""
|
||||||
|
pixels = np.ascontiguousarray(pixels, dtype=np.uint8)
|
||||||
|
n = len(pixels)
|
||||||
|
t = self.timeout_secs
|
||||||
|
if n == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if self.rgbw and n <= _MAX_DRGBW:
|
||||||
|
buf = self._ensure_rgbw_buf(n)
|
||||||
|
buf[:, 0:3] = pixels
|
||||||
|
# white channel already zeroed and left at 0
|
||||||
|
return [bytes([_DRGBW, t]) + buf.tobytes()]
|
||||||
|
|
||||||
|
if n <= _MAX_DRGB and not self.rgbw:
|
||||||
|
return [bytes([_DRGB, t]) + pixels.tobytes()]
|
||||||
|
|
||||||
|
# DNRGB: 16-bit start index, chunked. Covers >490 RGB and >367 RGBW
|
||||||
|
# (the white channel is dropped for oversized RGBW strips).
|
||||||
|
packets: list[bytes] = []
|
||||||
|
for start in range(0, n, _MAX_DNRGB_CHUNK):
|
||||||
|
end = min(start + _MAX_DNRGB_CHUNK, n)
|
||||||
|
header = bytes([_DNRGB, t, (start >> 8) & 0xFF, start & 0xFF])
|
||||||
|
packets.append(header + pixels[start:end].tobytes())
|
||||||
|
return packets
|
||||||
|
|
||||||
|
def send_pixels_numpy(self, pixels: np.ndarray) -> bool:
|
||||||
|
"""Send one frame of ``(N, 3)`` uint8 RGB pixels (fire-and-forget)."""
|
||||||
|
if self._transport is None:
|
||||||
|
return False
|
||||||
|
for packet in self.build_packets(pixels):
|
||||||
|
self._transport.sendto(packet)
|
||||||
|
return True
|
||||||
@@ -260,7 +260,10 @@ class CS2Adapter(GameAdapter):
|
|||||||
|
|
||||||
auth_section = payload.get("auth", {})
|
auth_section = payload.get("auth", {})
|
||||||
actual_token = auth_section.get("token", "")
|
actual_token = auth_section.get("token", "")
|
||||||
return bool(actual_token and actual_token == expected_token)
|
if not actual_token:
|
||||||
|
return False
|
||||||
|
# Constant-time comparison to avoid a timing oracle.
|
||||||
|
return secrets.compare_digest(actual_token, expected_token)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_config_schema(cls) -> dict[str, Any]:
|
def get_config_schema(cls) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -177,7 +177,10 @@ class Dota2Adapter(GameAdapter):
|
|||||||
|
|
||||||
auth_section = payload.get("auth", {})
|
auth_section = payload.get("auth", {})
|
||||||
actual_token = auth_section.get("token", "")
|
actual_token = auth_section.get("token", "")
|
||||||
return bool(actual_token and actual_token == expected_token)
|
if not actual_token:
|
||||||
|
return False
|
||||||
|
# Constant-time comparison to avoid a timing oracle.
|
||||||
|
return secrets.compare_digest(actual_token, expected_token)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_config_schema(cls) -> dict[str, Any]:
|
def get_config_schema(cls) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Allows users to define custom JSON path mappings via the adapter_config
|
|||||||
rather than a YAML file. Delegates all parsing logic to MappingAdapter.
|
rather than a YAML file. Delegates all parsing logic to MappingAdapter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from ledgrab.core.game_integration.base_adapter import GameAdapter
|
from ledgrab.core.game_integration.base_adapter import GameAdapter
|
||||||
@@ -54,11 +55,18 @@ class GenericWebhookAdapter(GameAdapter):
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
adapter_config: dict[str, Any],
|
adapter_config: dict[str, Any],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Validate auth using a configurable header token."""
|
"""Validate auth using a configurable header token.
|
||||||
|
|
||||||
|
Secure-by-default: this adapter is explicitly network-facing
|
||||||
|
(it accepts unauthenticated HTTP POSTs from anywhere on the LAN),
|
||||||
|
so a missing/empty ``auth_token`` REJECTS the request rather than
|
||||||
|
accepting it. A token must be configured for ingestion to work.
|
||||||
|
"""
|
||||||
expected_token = adapter_config.get("auth_token")
|
expected_token = adapter_config.get("auth_token")
|
||||||
if not expected_token:
|
if not expected_token:
|
||||||
# No auth configured
|
# No token configured — reject (secure-by-default for a
|
||||||
return True
|
# network-facing adapter; an open webhook is a LAN attack surface).
|
||||||
|
return False
|
||||||
|
|
||||||
auth_header = adapter_config.get("auth_header", "Authorization")
|
auth_header = adapter_config.get("auth_header", "Authorization")
|
||||||
actual_value = headers.get(auth_header, "")
|
actual_value = headers.get(auth_header, "")
|
||||||
@@ -67,18 +75,27 @@ class GenericWebhookAdapter(GameAdapter):
|
|||||||
if actual_value.startswith("Bearer "):
|
if actual_value.startswith("Bearer "):
|
||||||
actual_value = actual_value[7:]
|
actual_value = actual_value[7:]
|
||||||
|
|
||||||
return bool(actual_value and actual_value == expected_token)
|
if not actual_value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Constant-time comparison to avoid a token-length/timing oracle.
|
||||||
|
return secrets.compare_digest(actual_value, expected_token)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_config_schema(cls) -> dict[str, Any]:
|
def get_config_schema(cls) -> dict[str, Any]:
|
||||||
"""Return generic webhook config schema."""
|
"""Return generic webhook config schema."""
|
||||||
return {
|
return {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"required": ["auth_token"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"auth_token": {
|
"auth_token": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Auth Token",
|
"title": "Auth Token",
|
||||||
"description": "Optional token for authenticating incoming webhooks.",
|
"description": (
|
||||||
|
"Required token for authenticating incoming webhooks. "
|
||||||
|
"Without it, ingestion is rejected (this adapter is "
|
||||||
|
"network-facing and secure-by-default)."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"auth_header": {
|
"auth_header": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -136,15 +153,17 @@ class GenericWebhookAdapter(GameAdapter):
|
|||||||
"HTTP POST requests with JSON payloads.\n\n"
|
"HTTP POST requests with JSON payloads.\n\n"
|
||||||
"**Steps:**\n"
|
"**Steps:**\n"
|
||||||
"1. Configure your event mappings above — map JSON paths to standard events\n"
|
"1. Configure your event mappings above — map JSON paths to standard events\n"
|
||||||
"2. Set an auth token (optional but recommended)\n"
|
"2. Set an auth token (REQUIRED — without it incoming webhooks are rejected)\n"
|
||||||
"3. Point your game/application to:\n"
|
"3. Point your game/application to:\n"
|
||||||
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
|
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
|
||||||
"**Mapping example:**\n"
|
"**Mapping example:**\n"
|
||||||
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n"
|
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n"
|
||||||
"- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n"
|
"- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n"
|
||||||
"**Auth:**\n"
|
"**Auth (required):**\n"
|
||||||
"- Set `Authorization: Bearer <token>` header in your webhook sender\n"
|
"- Set `Authorization: Bearer <token>` header in your webhook sender\n"
|
||||||
"- Or configure a custom auth header name in the adapter config\n"
|
"- Or configure a custom auth header name in the adapter config\n"
|
||||||
|
"- A token is mandatory: this endpoint is reachable from the LAN, so\n"
|
||||||
|
" an unconfigured token rejects all incoming events.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ The MappingAdapter class is a concrete GameAdapter whose behavior is
|
|||||||
entirely driven by the parsed YAML definition.
|
entirely driven by the parsed YAML definition.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -241,7 +242,10 @@ class MappingAdapter(GameAdapter):
|
|||||||
expected_key = "auth_token"
|
expected_key = "auth_token"
|
||||||
expected_value = adapter_config.get(expected_key, "")
|
expected_value = adapter_config.get(expected_key, "")
|
||||||
actual_value = headers.get(header_name, "")
|
actual_value = headers.get(header_name, "")
|
||||||
return bool(expected_value and actual_value == expected_value)
|
if not (expected_value and actual_value):
|
||||||
|
return False
|
||||||
|
# Constant-time comparison to avoid a timing oracle.
|
||||||
|
return secrets.compare_digest(actual_value, expected_value)
|
||||||
|
|
||||||
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
|
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from ledgrab.core.capture.calibration import (
|
|||||||
CalibrationConfig,
|
CalibrationConfig,
|
||||||
create_pixel_mapper,
|
create_pixel_mapper,
|
||||||
)
|
)
|
||||||
from ledgrab.core.capture.screen_capture import extract_border_pixels
|
from ledgrab.core.capture.screen_capture import crop_screen_capture, extract_border_pixels
|
||||||
from ledgrab.storage.bindable import bfloat
|
from ledgrab.storage.bindable import bfloat
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
from ledgrab.utils.frame_limiter import FrameLimiter
|
from ledgrab.utils.frame_limiter import FrameLimiter
|
||||||
@@ -296,7 +296,19 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
led_colors = mapper.map_lines_to_leds(frames_dict)
|
led_colors = mapper.map_lines_to_leds(frames_dict)
|
||||||
else:
|
else:
|
||||||
border_pixels = extract_border_pixels(frame, calibration.border_width)
|
src = frame
|
||||||
|
bw = calibration.border_width
|
||||||
|
if calibration.has_roi:
|
||||||
|
src = crop_screen_capture(
|
||||||
|
frame,
|
||||||
|
calibration.roi_x,
|
||||||
|
calibration.roi_y,
|
||||||
|
calibration.roi_width,
|
||||||
|
calibration.roi_height,
|
||||||
|
)
|
||||||
|
# Border width must stay within the cropped size.
|
||||||
|
bw = max(1, min(bw, min(src.width, src.height) // 4))
|
||||||
|
border_pixels = extract_border_pixels(src, bw)
|
||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
led_colors = mapper.map_border_to_leds(border_pixels)
|
led_colors = mapper.map_border_to_leds(border_pixels)
|
||||||
t2 = time.perf_counter()
|
t2 = time.perf_counter()
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
|
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
|
||||||
self._resize_cache: Dict[tuple, tuple] = {}
|
self._resize_cache: Dict[tuple, tuple] = {}
|
||||||
|
# (src_len, target_n) -> (src_x, dst_x) cache for full-strip resizing
|
||||||
|
# (output reuses the preallocated self._resize_buf from _ensure_pool)
|
||||||
|
self._resize_linspace_cache: Dict[tuple, tuple] = {}
|
||||||
# layer_index -> (source_id, consumer_id, stream)
|
# layer_index -> (source_id, consumer_id, stream)
|
||||||
self._sub_streams: Dict[int, tuple] = {}
|
self._sub_streams: Dict[int, tuple] = {}
|
||||||
# layer_index -> (vs_id, value_stream)
|
# layer_index -> (vs_id, value_stream)
|
||||||
@@ -314,8 +317,14 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
n_src = len(colors)
|
n_src = len(colors)
|
||||||
if n_src == target_n:
|
if n_src == target_n:
|
||||||
return colors
|
return colors
|
||||||
src_x = np.linspace(0, 1, n_src)
|
# Cache the (src_x, dst_x) linspace arrays keyed by (n_src, target_n)
|
||||||
dst_x = np.linspace(0, 1, target_n)
|
# exactly like the zone path, so they are not reallocated every frame.
|
||||||
|
lkey = (n_src, target_n)
|
||||||
|
linspaces = self._resize_linspace_cache.get(lkey)
|
||||||
|
if linspaces is None:
|
||||||
|
linspaces = (np.linspace(0, 1, n_src), np.linspace(0, 1, target_n))
|
||||||
|
self._resize_linspace_cache[lkey] = linspaces
|
||||||
|
src_x, dst_x = linspaces
|
||||||
buf = self._resize_buf
|
buf = self._resize_buf
|
||||||
for ch in range(3):
|
for ch in range(3):
|
||||||
np.copyto(
|
np.copyto(
|
||||||
|
|||||||
@@ -137,16 +137,34 @@ class DeviceTestModeMixin:
|
|||||||
await self._send_pixels_to_device(device_id, pixels)
|
await self._send_pixels_to_device(device_id, pixels)
|
||||||
|
|
||||||
async def _send_clear_pixels(self, device_id: str) -> None:
|
async def _send_clear_pixels(self, device_id: str) -> None:
|
||||||
"""Send all-black pixels to clear LED output."""
|
"""Send all-black pixels to clear LED output.
|
||||||
|
|
||||||
|
This is the explicit teardown path — unlike the per-frame
|
||||||
|
``_send_pixels_to_device`` swallow, a clear that must actually take
|
||||||
|
effect retries once before giving up, so a single transient send
|
||||||
|
error doesn't leave the device lit after a session ends.
|
||||||
|
"""
|
||||||
ds = self._devices[device_id]
|
ds = self._devices[device_id]
|
||||||
pixels = [(0, 0, 0)] * ds.led_count
|
pixels = [(0, 0, 0)] * ds.led_count
|
||||||
await self._send_pixels_to_device(device_id, pixels)
|
try:
|
||||||
|
client = await self._get_idle_client(device_id)
|
||||||
|
await client.send_pixels(pixels)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Clear send to {device_id} failed, retrying once: {e}")
|
||||||
|
client = await self._get_idle_client(device_id)
|
||||||
|
await client.send_pixels(pixels)
|
||||||
|
|
||||||
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
|
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
|
||||||
"""Send pixels to a device via cached idle client.
|
"""Send pixels to a device via cached idle client.
|
||||||
|
|
||||||
Reuses a cached connection to avoid repeated serial reconnections
|
Reuses a cached connection to avoid repeated serial reconnections
|
||||||
(which trigger Arduino bootloader reset on Adalight devices).
|
(which trigger Arduino bootloader reset on Adalight devices).
|
||||||
|
|
||||||
|
Send failures are logged and swallowed (best-effort per-frame send).
|
||||||
|
Callers that need a *guaranteed* clear — e.g. session teardown that
|
||||||
|
must "never leave the device dark" — must NOT rely on this returning
|
||||||
|
cleanly; use ``_send_clear_pixels`` (which retries once) and treat a
|
||||||
|
propagated exception as a failed clear.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = await self._get_idle_client(device_id)
|
client = await self._get_idle_client(device_id)
|
||||||
|
|||||||
@@ -973,13 +973,18 @@ class EffectColorStripStream(ColorStripStream):
|
|||||||
# Use noise at very low frequency for blob movement
|
# Use noise at very low frequency for blob movement
|
||||||
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)
|
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)
|
||||||
|
|
||||||
# Two blob layers at different speeds for organic movement
|
# Two blob layers at different speeds for organic movement.
|
||||||
|
# fbm() returns a shared internal buffer that the next fbm() call
|
||||||
|
# overwrites, so each layer must be copied out — write into the
|
||||||
|
# preallocated scratch buffers instead of allocating per frame.
|
||||||
self._s_f32_a += t * speed * 0.1
|
self._s_f32_a += t * speed * 0.1
|
||||||
layer1 = self._noise.fbm(self._s_f32_a, octaves=3).copy()
|
layer1 = self._s_layer1
|
||||||
|
np.copyto(layer1, self._noise.fbm(self._s_f32_a, octaves=3))
|
||||||
|
|
||||||
np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
|
np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
|
||||||
self._s_f32_a += t * speed * 0.07 + 100.0
|
self._s_f32_a += t * speed * 0.07 + 100.0
|
||||||
layer2 = self._noise.fbm(self._s_f32_a, octaves=2).copy()
|
layer2 = self._s_layer2
|
||||||
|
np.copyto(layer2, self._noise.fbm(self._s_f32_a, octaves=2))
|
||||||
|
|
||||||
# Combine: create blob-like shapes with soft edges
|
# Combine: create blob-like shapes with soft edges
|
||||||
combined = self._s_f32_a
|
combined = self._s_f32_a
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Automatic brightness limiting (ABL) — keep a strip within a PSU current budget.
|
||||||
|
|
||||||
|
Estimates the current an addressable LED strip would draw for a frame of
|
||||||
|
already-brightness-scaled RGB bytes and, if it exceeds the configured budget,
|
||||||
|
returns a uniform scale factor to bring it back under budget. This prevents the
|
||||||
|
classic under-spec'd-PSU failure mode: a full-white scene browning out the rail
|
||||||
|
(voltage sag -> red/orange shift, flicker, controller resets) — which reads to a
|
||||||
|
new user as "this software is broken".
|
||||||
|
|
||||||
|
Model: one addressable LED at full white ``(255, 255, 255)`` draws
|
||||||
|
``milliamps_per_led`` mA, and current scales linearly with the sum of channel
|
||||||
|
values, so a frame's draw is::
|
||||||
|
|
||||||
|
estimated_ma = sum(channel_bytes) * milliamps_per_led / (255 * 3)
|
||||||
|
|
||||||
|
(``255 * 3 = 765`` channel-units == one LED at full white.) Standby/idle current
|
||||||
|
is intentionally ignored: the limiter only needs to catch the high-draw frames
|
||||||
|
that cause brownouts, and the default 55 mA/LED already carries real-world
|
||||||
|
headroom. The same convention as WLED's "maximum current" setting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Channel units in one LED at full white (R + G + B = 255 * 3).
|
||||||
|
_FULL_WHITE_UNITS = 765.0
|
||||||
|
|
||||||
|
# Typical full-white draw of a single WS2812/SK6812-class LED, in mA.
|
||||||
|
DEFAULT_MILLIAMPS_PER_LED = 55
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_current_ma(colors: np.ndarray, milliamps_per_led: int) -> float:
|
||||||
|
"""Estimate strip draw (mA) for already-brightness-scaled RGB bytes.
|
||||||
|
|
||||||
|
``colors`` is an ``(N, 3)`` uint8 array of the values actually sent to the
|
||||||
|
strip. Full white over ``N`` LEDs returns ``N * milliamps_per_led``.
|
||||||
|
"""
|
||||||
|
if milliamps_per_led <= 0 or colors.size == 0:
|
||||||
|
return 0.0
|
||||||
|
channel_sum = float(int(colors.sum()))
|
||||||
|
return channel_sum * milliamps_per_led / _FULL_WHITE_UNITS
|
||||||
|
|
||||||
|
|
||||||
|
def power_limit_scale(colors: np.ndarray, max_milliamps: int, milliamps_per_led: int) -> float:
|
||||||
|
"""Return a scale in ``(0, 1]`` that keeps estimated draw within budget.
|
||||||
|
|
||||||
|
Returns ``1.0`` when limiting is disabled (``max_milliamps <= 0``) or the
|
||||||
|
frame is already within budget. Because current is linear in the channel
|
||||||
|
values, scaling every pixel by ``max_milliamps / estimated`` lands the frame
|
||||||
|
exactly on the budget.
|
||||||
|
"""
|
||||||
|
if max_milliamps <= 0 or milliamps_per_led <= 0:
|
||||||
|
return 1.0
|
||||||
|
estimated = estimate_current_ma(colors, milliamps_per_led)
|
||||||
|
if estimated <= max_milliamps:
|
||||||
|
return 1.0
|
||||||
|
return max_milliamps / estimated
|
||||||
@@ -44,6 +44,7 @@ from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
|||||||
from ledgrab.core.weather.weather_manager import WeatherManager
|
from ledgrab.core.weather.weather_manager import WeatherManager
|
||||||
from ledgrab.core.processing.device_health import DeviceHealthMixin
|
from ledgrab.core.processing.device_health import DeviceHealthMixin
|
||||||
from ledgrab.core.processing.device_test_mode import DeviceTestModeMixin
|
from ledgrab.core.processing.device_test_mode import DeviceTestModeMixin
|
||||||
|
from ledgrab.core.capture.calibration_session import CalibrationChaseMixin
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -106,7 +107,9 @@ class DeviceState:
|
|||||||
zone_mode: str = "combined"
|
zone_mode: str = "combined"
|
||||||
|
|
||||||
|
|
||||||
class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin):
|
class ProcessorManager(
|
||||||
|
AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin, CalibrationChaseMixin
|
||||||
|
):
|
||||||
"""Manages devices and delegates target processing to TargetProcessor instances.
|
"""Manages devices and delegates target processing to TargetProcessor instances.
|
||||||
|
|
||||||
Devices are registered for health monitoring.
|
Devices are registered for health monitoring.
|
||||||
@@ -407,6 +410,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
adaptive_fps: bool = False,
|
adaptive_fps: bool = False,
|
||||||
protocol: str = "ddp",
|
protocol: str = "ddp",
|
||||||
|
max_milliamps: int = 0,
|
||||||
|
milliamps_per_led: int = 55,
|
||||||
):
|
):
|
||||||
"""Register a WLED target processor."""
|
"""Register a WLED target processor."""
|
||||||
if target_id in self._processors:
|
if target_id in self._processors:
|
||||||
@@ -425,6 +430,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
min_brightness_threshold=min_brightness_threshold,
|
min_brightness_threshold=min_brightness_threshold,
|
||||||
adaptive_fps=adaptive_fps,
|
adaptive_fps=adaptive_fps,
|
||||||
protocol=protocol,
|
protocol=protocol,
|
||||||
|
max_milliamps=max_milliamps,
|
||||||
|
milliamps_per_led=milliamps_per_led,
|
||||||
ctx=self._build_context(),
|
ctx=self._build_context(),
|
||||||
)
|
)
|
||||||
self._processors[target_id] = proc
|
self._processors[target_id] = proc
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from ledgrab.core.devices.led_client import (
|
|||||||
get_device_capabilities,
|
get_device_capabilities,
|
||||||
)
|
)
|
||||||
from ledgrab.core.capture.screen_capture import get_available_displays
|
from ledgrab.core.capture.screen_capture import get_available_displays
|
||||||
|
from ledgrab.core.processing.power_limit import DEFAULT_MILLIAMPS_PER_LED, power_limit_scale
|
||||||
from ledgrab.core.processing.target_processor import (
|
from ledgrab.core.processing.target_processor import (
|
||||||
ProcessingMetrics,
|
ProcessingMetrics,
|
||||||
TargetContext,
|
TargetContext,
|
||||||
@@ -62,6 +63,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
adaptive_fps: bool = False,
|
adaptive_fps: bool = False,
|
||||||
protocol: str = "ddp",
|
protocol: str = "ddp",
|
||||||
|
max_milliamps: int = 0,
|
||||||
|
milliamps_per_led: int = 55,
|
||||||
ctx: TargetContext = None,
|
ctx: TargetContext = None,
|
||||||
):
|
):
|
||||||
from ledgrab.storage.bindable import BindableFloat, bfloat
|
from ledgrab.storage.bindable import BindableFloat, bfloat
|
||||||
@@ -81,6 +84,13 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
|
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
|
||||||
self._adaptive_fps = adaptive_fps
|
self._adaptive_fps = adaptive_fps
|
||||||
self._protocol = protocol
|
self._protocol = protocol
|
||||||
|
# Automatic brightness limiting (ABL). 0 mA budget = disabled.
|
||||||
|
self._max_milliamps = max(0, int(max_milliamps or 0))
|
||||||
|
self._milliamps_per_led = max(1, int(milliamps_per_led or DEFAULT_MILLIAMPS_PER_LED))
|
||||||
|
# Reusable scratch for in-place power scaling (allocated on first use).
|
||||||
|
self._power_u16: np.ndarray | None = None
|
||||||
|
self._power_out: np.ndarray | None = None
|
||||||
|
self._power_n = 0
|
||||||
|
|
||||||
# Adaptive FPS / liveness probe runtime state
|
# Adaptive FPS / liveness probe runtime state
|
||||||
self._effective_fps: int = self._target_fps
|
self._effective_fps: int = self._target_fps
|
||||||
@@ -146,9 +156,15 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
from ledgrab.core.devices.device_config import WLEDConfig as _WLEDConfig
|
from ledgrab.core.devices.device_config import WLEDConfig as _WLEDConfig
|
||||||
|
|
||||||
config = _dev.to_config()
|
config = _dev.to_config()
|
||||||
# use_ddp is a target-derived protocol setting — override on WLEDConfig
|
# The target's protocol selects how we drive a WLED device:
|
||||||
|
# "ddp" -> DDP UDP (4048) "udp" -> WLED native realtime UDP (21324)
|
||||||
|
# "http" -> JSON API (use_ddp and use_realtime are exclusive)
|
||||||
if isinstance(config, _WLEDConfig):
|
if isinstance(config, _WLEDConfig):
|
||||||
config = _replace(config, use_ddp=(self._protocol == "ddp"))
|
config = _replace(
|
||||||
|
config,
|
||||||
|
use_ddp=(self._protocol == "ddp"),
|
||||||
|
use_realtime=(self._protocol == "udp"),
|
||||||
|
)
|
||||||
self._device_config = config
|
self._device_config = config
|
||||||
|
|
||||||
# Connect to LED device
|
# Connect to LED device
|
||||||
@@ -313,6 +329,12 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._adaptive_fps = settings["adaptive_fps"]
|
self._adaptive_fps = settings["adaptive_fps"]
|
||||||
if not self._adaptive_fps:
|
if not self._adaptive_fps:
|
||||||
self._effective_fps = self._target_fps
|
self._effective_fps = self._target_fps
|
||||||
|
if "max_milliamps" in settings:
|
||||||
|
self._max_milliamps = max(0, int(settings["max_milliamps"] or 0))
|
||||||
|
if "milliamps_per_led" in settings:
|
||||||
|
self._milliamps_per_led = max(
|
||||||
|
1, int(settings["milliamps_per_led"] or DEFAULT_MILLIAMPS_PER_LED)
|
||||||
|
)
|
||||||
logger.info(f"Updated settings for target {self._target_id}")
|
logger.info(f"Updated settings for target {self._target_id}")
|
||||||
|
|
||||||
def update_device(self, device_id: str) -> None:
|
def update_device(self, device_id: str) -> None:
|
||||||
@@ -717,10 +739,19 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
self._last_preview_data = data
|
self._last_preview_data = data
|
||||||
|
|
||||||
async def _send_safe(ws):
|
# Bound each per-client send to roughly one frame interval so a slow or
|
||||||
|
# backpressured preview WebSocket can never throttle the device send
|
||||||
|
# cadence. Clients that time out or error are dropped from the set.
|
||||||
|
eff_fps = self._effective_fps if self._effective_fps > 0 else 30
|
||||||
|
send_timeout = 1.0 / eff_fps
|
||||||
|
|
||||||
|
async def _send_safe(ws) -> bool:
|
||||||
try:
|
try:
|
||||||
await ws.send_bytes(data)
|
await asyncio.wait_for(ws.send_bytes(data), timeout=send_timeout)
|
||||||
return True
|
return True
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug("LED preview broadcast WS send timed out (slow client dropped)")
|
||||||
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("LED preview broadcast WS send failed: %s", e)
|
logger.debug("LED preview broadcast WS send failed: %s", e)
|
||||||
return False
|
return False
|
||||||
@@ -787,8 +818,33 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
np.copyto(out, blend, casting="unsafe") # float32 → uint8
|
np.copyto(out, blend, casting="unsafe") # float32 → uint8
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def _apply_power_limit(self, colors: np.ndarray) -> np.ndarray:
|
||||||
|
"""Scale ``colors`` down to stay within the PSU current budget (ABL).
|
||||||
|
|
||||||
|
Returns ``colors`` unchanged when limiting is disabled or the frame is
|
||||||
|
already within budget; otherwise returns a scaled copy in a reusable
|
||||||
|
scratch buffer (the input is never mutated — it may be a shared frame).
|
||||||
|
"""
|
||||||
|
if self._max_milliamps <= 0:
|
||||||
|
return colors
|
||||||
|
scale = power_limit_scale(colors, self._max_milliamps, self._milliamps_per_led)
|
||||||
|
if scale >= 1.0:
|
||||||
|
return colors
|
||||||
|
factor = int(scale * 256) # 0..255 fixed-point multiplier
|
||||||
|
n = len(colors)
|
||||||
|
if self._power_u16 is None or self._power_n != n:
|
||||||
|
self._power_n = n
|
||||||
|
self._power_u16 = np.empty((n, 3), dtype=np.uint16)
|
||||||
|
self._power_out = np.empty((n, 3), dtype=np.uint8)
|
||||||
|
np.copyto(self._power_u16, colors, casting="unsafe")
|
||||||
|
self._power_u16 *= factor
|
||||||
|
self._power_u16 >>= 8
|
||||||
|
np.copyto(self._power_out, self._power_u16, casting="unsafe")
|
||||||
|
return self._power_out
|
||||||
|
|
||||||
async def _send_to_device(self, send_colors: np.ndarray) -> float:
|
async def _send_to_device(self, send_colors: np.ndarray) -> float:
|
||||||
"""Send colors to LED device and return send time in ms."""
|
"""Send colors to LED device and return send time in ms."""
|
||||||
|
send_colors = self._apply_power_limit(send_colors)
|
||||||
t_start = time.perf_counter()
|
t_start = time.perf_counter()
|
||||||
if self._led_client.supports_fast_send:
|
if self._led_client.supports_fast_send:
|
||||||
self._led_client.send_pixels_fast(send_colors)
|
self._led_client.send_pixels_fast(send_colors)
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
"""Playlist engine — background loop that auto-cycles a scene playlist.
|
||||||
|
|
||||||
|
A playlist is an ordered, timed sequence of scene presets. The engine drives
|
||||||
|
**at most one** playlist at a time: starting a new playlist transparently stops
|
||||||
|
any currently-running one. Each cycle re-reads the playlist from the store, so
|
||||||
|
edits (and deletion) take effect at the next cycle boundary without a restart.
|
||||||
|
|
||||||
|
The actual state application reuses ``scene_activator.apply_scene_state`` — the
|
||||||
|
same code path the scene-presets API and the automation engine use — so a
|
||||||
|
playlist step behaves exactly like manually activating that preset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ledgrab.storage.scene_playlist import ScenePlaylist, clamp_duration
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlaylistRuntimeState:
|
||||||
|
"""Volatile runtime state of the (single) active playlist. Not persisted."""
|
||||||
|
|
||||||
|
playlist_id: str
|
||||||
|
playlist_name: str
|
||||||
|
current_index: int
|
||||||
|
item_count: int
|
||||||
|
current_preset_id: str | None
|
||||||
|
started_at: datetime
|
||||||
|
step_started_at: datetime
|
||||||
|
step_duration: float
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"is_running": True,
|
||||||
|
"playlist_id": self.playlist_id,
|
||||||
|
"playlist_name": self.playlist_name,
|
||||||
|
"current_index": self.current_index,
|
||||||
|
"item_count": self.item_count,
|
||||||
|
"current_preset_id": self.current_preset_id,
|
||||||
|
"started_at": self.started_at.isoformat(),
|
||||||
|
"step_started_at": self.step_started_at.isoformat(),
|
||||||
|
"step_duration": self.step_duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_IDLE_STATE = {
|
||||||
|
"is_running": False,
|
||||||
|
"playlist_id": None,
|
||||||
|
"playlist_name": None,
|
||||||
|
"current_index": 0,
|
||||||
|
"item_count": 0,
|
||||||
|
"current_preset_id": None,
|
||||||
|
"started_at": None,
|
||||||
|
"step_started_at": None,
|
||||||
|
"step_duration": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistError(Exception):
|
||||||
|
"""Raised when a playlist cannot be started (empty / not found)."""
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistEngine:
|
||||||
|
"""Cycles a scene playlist's presets on a timer, one playlist at a time."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
playlist_store,
|
||||||
|
scene_preset_store,
|
||||||
|
target_store,
|
||||||
|
processor_manager,
|
||||||
|
):
|
||||||
|
self._playlist_store = playlist_store
|
||||||
|
self._scene_preset_store = scene_preset_store
|
||||||
|
self._target_store = target_store
|
||||||
|
self._manager = processor_manager
|
||||||
|
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._state: PlaylistRuntimeState | None = None
|
||||||
|
# Serialises start/stop so overlapping API calls can't leave two
|
||||||
|
# cycling tasks alive at once.
|
||||||
|
self._lifecycle_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ===== Public control API =====
|
||||||
|
|
||||||
|
async def start_playlist(self, playlist_id: str) -> PlaylistRuntimeState:
|
||||||
|
"""Start cycling ``playlist_id``, stopping any current playlist first.
|
||||||
|
|
||||||
|
Raises ``PlaylistError`` if the playlist is unknown or has no items.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
playlist = self._playlist_store.get_playlist(playlist_id)
|
||||||
|
except Exception as exc: # EntityNotFoundError / ValueError
|
||||||
|
raise PlaylistError(f"Playlist not found: {playlist_id}") from exc
|
||||||
|
|
||||||
|
if not playlist.items:
|
||||||
|
raise PlaylistError(f"Playlist '{playlist.name}' has no items")
|
||||||
|
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
await self._cancel_task()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
first_item = playlist.items[0]
|
||||||
|
self._state = PlaylistRuntimeState(
|
||||||
|
playlist_id=playlist.id,
|
||||||
|
playlist_name=playlist.name,
|
||||||
|
current_index=0,
|
||||||
|
item_count=len(playlist.items),
|
||||||
|
current_preset_id=first_item.scene_preset_id,
|
||||||
|
started_at=now,
|
||||||
|
step_started_at=now,
|
||||||
|
step_duration=clamp_duration(first_item.duration_seconds),
|
||||||
|
)
|
||||||
|
self._task = asyncio.create_task(self._run(playlist.id))
|
||||||
|
|
||||||
|
self._fire_event("started")
|
||||||
|
logger.info("Playlist '%s' started (%d items)", playlist.name, len(playlist.items))
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the active playlist (if any). Leaves the last scene applied."""
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
was_running = self._task is not None
|
||||||
|
await self._cancel_task()
|
||||||
|
stopped_id = self._state.playlist_id if self._state else None
|
||||||
|
self._state = None
|
||||||
|
if was_running:
|
||||||
|
self._fire_event("stopped", playlist_id=stopped_id)
|
||||||
|
logger.info("Playlist stopped")
|
||||||
|
|
||||||
|
async def stop_if_running(self, playlist_id: str) -> None:
|
||||||
|
"""Stop the playlist only if ``playlist_id`` is the one running.
|
||||||
|
|
||||||
|
Used when a playlist is deleted or edited so a stale snapshot can't keep
|
||||||
|
cycling. The read-compare and the stop happen atomically under the
|
||||||
|
lifecycle lock so a concurrent natural-end / start can't slip a
|
||||||
|
different playlist in between the check and the stop.
|
||||||
|
"""
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
state = self._state
|
||||||
|
if state is None or state.playlist_id != playlist_id:
|
||||||
|
return
|
||||||
|
was_running = self._task is not None
|
||||||
|
await self._cancel_task()
|
||||||
|
stopped_id = self._state.playlist_id if self._state else playlist_id
|
||||||
|
self._state = None
|
||||||
|
if was_running:
|
||||||
|
self._fire_event("stopped", playlist_id=stopped_id)
|
||||||
|
logger.info("Playlist stopped")
|
||||||
|
|
||||||
|
# ===== Query API (used by routes) =====
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
# Snapshot the task ref so a concurrent clear (set to None at an await
|
||||||
|
# boundary) can't turn the deref into an attribute error mid-read.
|
||||||
|
task = self._task
|
||||||
|
return task is not None and not task.done()
|
||||||
|
|
||||||
|
def get_running_playlist_id(self) -> str | None:
|
||||||
|
state = self._state
|
||||||
|
return state.playlist_id if state else None
|
||||||
|
|
||||||
|
def get_state(self) -> dict:
|
||||||
|
# Snapshot both refs once: the event loop won't preempt this sync method
|
||||||
|
# between the reads, but snapshotting also guards against ever returning
|
||||||
|
# a half-cleared state (running True while _state is already None).
|
||||||
|
state = self._state
|
||||||
|
task = self._task
|
||||||
|
if state is not None and task is not None and not task.done():
|
||||||
|
return state.to_dict()
|
||||||
|
return dict(_IDLE_STATE)
|
||||||
|
|
||||||
|
# ===== Internal =====
|
||||||
|
|
||||||
|
async def _cancel_task(self) -> None:
|
||||||
|
"""Cancel and await the cycling task. Caller holds the lifecycle lock."""
|
||||||
|
task = self._task
|
||||||
|
self._task = None
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error("Playlist task raised on cancel: %s", exc, exc_info=True)
|
||||||
|
|
||||||
|
async def _run(self, playlist_id: str) -> None:
|
||||||
|
"""Cycle the playlist until cancelled, the playlist ends, or it errors."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Re-read each cycle so edits/deletes apply at the boundary.
|
||||||
|
try:
|
||||||
|
playlist = self._playlist_store.get_playlist(playlist_id)
|
||||||
|
except Exception:
|
||||||
|
logger.info("Playlist %s removed while running; stopping", playlist_id)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not playlist.items:
|
||||||
|
logger.info("Playlist '%s' has no items; stopping", playlist.name)
|
||||||
|
break
|
||||||
|
|
||||||
|
applied_any = await self._run_cycle(playlist)
|
||||||
|
|
||||||
|
if not playlist.loop:
|
||||||
|
break
|
||||||
|
if not applied_any:
|
||||||
|
# Every item referenced a missing preset — a looping
|
||||||
|
# playlist would otherwise spin with no dwell. Bail out.
|
||||||
|
logger.warning(
|
||||||
|
"Playlist '%s' applied no valid presets this cycle; stopping",
|
||||||
|
playlist.name,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Natural end (non-loop or guard). Clear state without recursing
|
||||||
|
# through stop() (which would try to cancel this very task). Take
|
||||||
|
# the lifecycle lock so the clear + 'stopped' event are atomic with
|
||||||
|
# respect to a concurrent start/stop/stop_if_running — otherwise the
|
||||||
|
# two could interleave and emit duplicate or contradictory terminal
|
||||||
|
# events. _run never calls _cancel_task and never otherwise holds
|
||||||
|
# this lock, so acquiring it here cannot deadlock; if a canceller
|
||||||
|
# holding the lock cancels us while we wait to acquire it, the
|
||||||
|
# acquire raises CancelledError and we fall through to the handler.
|
||||||
|
ended_id = None
|
||||||
|
should_fire = False
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
# Re-check under the lock: a concurrent start_playlist may have
|
||||||
|
# replaced us while we waited. Only clear if we're still current.
|
||||||
|
if self._task is asyncio.current_task():
|
||||||
|
self._task = None
|
||||||
|
ended_id = self._state.playlist_id if self._state else None
|
||||||
|
self._state = None
|
||||||
|
should_fire = True
|
||||||
|
if should_fire:
|
||||||
|
self._fire_event("stopped", playlist_id=ended_id)
|
||||||
|
logger.info("Playlist '%s' finished", playlist_id)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error("Playlist run loop error: %s", exc, exc_info=True)
|
||||||
|
|
||||||
|
async def _run_cycle(self, playlist: ScenePlaylist) -> bool:
|
||||||
|
"""Run one pass over the playlist's items. Returns True if any applied."""
|
||||||
|
order = self._resolve_order(playlist)
|
||||||
|
applied_any = False
|
||||||
|
|
||||||
|
for index, item in enumerate(order):
|
||||||
|
duration = clamp_duration(item.duration_seconds)
|
||||||
|
if self._state is not None:
|
||||||
|
self._state.current_index = index
|
||||||
|
self._state.current_preset_id = item.scene_preset_id
|
||||||
|
self._state.step_started_at = datetime.now(timezone.utc)
|
||||||
|
self._state.step_duration = duration
|
||||||
|
|
||||||
|
applied = await self._apply_item(item.scene_preset_id)
|
||||||
|
if applied:
|
||||||
|
applied_any = True
|
||||||
|
self._fire_event("advanced", index=index, preset_id=item.scene_preset_id)
|
||||||
|
# Only dwell on scenes we actually applied; skip missing ones
|
||||||
|
# immediately so the cycle doesn't stall on a dead reference.
|
||||||
|
await asyncio.sleep(duration)
|
||||||
|
|
||||||
|
return applied_any
|
||||||
|
|
||||||
|
def _resolve_order(self, playlist: ScenePlaylist) -> List:
|
||||||
|
if playlist.shuffle and len(playlist.items) > 1:
|
||||||
|
shuffled = list(playlist.items)
|
||||||
|
random.shuffle(shuffled) # noqa: S311 - cosmetic ordering, not security
|
||||||
|
return shuffled
|
||||||
|
return list(playlist.items)
|
||||||
|
|
||||||
|
async def _apply_item(self, preset_id: str) -> bool:
|
||||||
|
"""Apply one scene preset. Returns False if it could not be applied."""
|
||||||
|
if not self._scene_preset_store or not self._target_store or not self._manager:
|
||||||
|
logger.warning("Playlist engine missing stores; cannot apply %s", preset_id)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
preset = self._scene_preset_store.get_preset(preset_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Playlist references missing scene preset %s (skipped)", preset_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
from ledgrab.core.scenes.scene_activator import apply_scene_state
|
||||||
|
|
||||||
|
_status, errors = await apply_scene_state(preset, self._target_store, self._manager)
|
||||||
|
if errors:
|
||||||
|
logger.warning("Playlist step '%s' applied with errors: %s", preset.name, errors)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _fire_event(self, action: str, **extra) -> None:
|
||||||
|
if self._manager is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._manager.fire_event(
|
||||||
|
{
|
||||||
|
"type": "playlist_state_changed",
|
||||||
|
"action": action,
|
||||||
|
"playlist_id": extra.get("playlist_id")
|
||||||
|
or (self._state.playlist_id if self._state else None),
|
||||||
|
**{k: v for k, v in extra.items() if k != "playlist_id"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Playlist event fire failed: %s", exc, exc_info=True)
|
||||||
@@ -35,6 +35,7 @@ import ledgrab.core.audio # noqa: F401 — trigger engine auto-registration
|
|||||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||||
from ledgrab.storage.automation_store import AutomationStore
|
from ledgrab.storage.automation_store import AutomationStore
|
||||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||||
from ledgrab.storage.color_strip_processing_template_store import (
|
from ledgrab.storage.color_strip_processing_template_store import (
|
||||||
ColorStripProcessingTemplateStore,
|
ColorStripProcessingTemplateStore,
|
||||||
@@ -47,6 +48,7 @@ from ledgrab.core.weather.weather_manager import WeatherManager
|
|||||||
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
||||||
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||||
|
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
||||||
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||||
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
|
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
|
||||||
@@ -157,6 +159,7 @@ audio_template_store = AudioTemplateStore(db)
|
|||||||
value_source_store = ValueSourceStore(db)
|
value_source_store = ValueSourceStore(db)
|
||||||
automation_store = AutomationStore(db)
|
automation_store = AutomationStore(db)
|
||||||
scene_preset_store = ScenePresetStore(db)
|
scene_preset_store = ScenePresetStore(db)
|
||||||
|
scene_playlist_store = ScenePlaylistStore(db)
|
||||||
sync_clock_store = SyncClockStore(db)
|
sync_clock_store = SyncClockStore(db)
|
||||||
cspt_store = ColorStripProcessingTemplateStore(db)
|
cspt_store = ColorStripProcessingTemplateStore(db)
|
||||||
gradient_store = GradientStore(db)
|
gradient_store = GradientStore(db)
|
||||||
@@ -278,6 +281,15 @@ async def lifespan(app: FastAPI):
|
|||||||
value_source_store=value_source_store,
|
value_source_store=value_source_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create playlist engine — auto-cycles scene presets, one playlist at a
|
||||||
|
# time. Idle (no background task) until a playlist is started via the API.
|
||||||
|
playlist_engine = PlaylistEngine(
|
||||||
|
playlist_store=scene_playlist_store,
|
||||||
|
scene_preset_store=scene_preset_store,
|
||||||
|
target_store=output_target_store,
|
||||||
|
processor_manager=processor_manager,
|
||||||
|
)
|
||||||
|
|
||||||
# Create auto-backup engine — derive paths from database location so that
|
# Create auto-backup engine — derive paths from database location so that
|
||||||
# demo mode auto-backups go to data/demo/ instead of data/.
|
# demo mode auto-backups go to data/demo/ instead of data/.
|
||||||
_data_dir = Path(config.storage.database_file).parent
|
_data_dir = Path(config.storage.database_file).parent
|
||||||
@@ -314,7 +326,9 @@ async def lifespan(app: FastAPI):
|
|||||||
value_source_store=value_source_store,
|
value_source_store=value_source_store,
|
||||||
automation_store=automation_store,
|
automation_store=automation_store,
|
||||||
scene_preset_store=scene_preset_store,
|
scene_preset_store=scene_preset_store,
|
||||||
|
scene_playlist_store=scene_playlist_store,
|
||||||
automation_engine=automation_engine,
|
automation_engine=automation_engine,
|
||||||
|
playlist_engine=playlist_engine,
|
||||||
auto_backup_engine=auto_backup_engine,
|
auto_backup_engine=auto_backup_engine,
|
||||||
sync_clock_store=sync_clock_store,
|
sync_clock_store=sync_clock_store,
|
||||||
sync_clock_manager=sync_clock_manager,
|
sync_clock_manager=sync_clock_manager,
|
||||||
@@ -436,6 +450,16 @@ async def lifespan(app: FastAPI):
|
|||||||
# would talk to processors mid-shutdown.
|
# would talk to processors mid-shutdown.
|
||||||
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
|
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
|
||||||
|
|
||||||
|
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
|
||||||
|
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
|
||||||
|
|
||||||
|
# Tear down any active calibration session BEFORE stop_all so the device
|
||||||
|
# isn't left stuck in the white-chase and its prior target is restored.
|
||||||
|
# stop() is a no-op when no session is active.
|
||||||
|
from ledgrab.core.capture.calibration_session import get_calibration_session
|
||||||
|
|
||||||
|
await _bounded("calibration_session.stop", get_calibration_session().stop(), timeout=1.0)
|
||||||
|
|
||||||
# Stop discovery watcher and OS notification listener so they stop
|
# Stop discovery watcher and OS notification listener so they stop
|
||||||
# firing events into a shutting-down processor manager.
|
# firing events into a shutting-down processor manager.
|
||||||
if discovery_watcher is not None:
|
if discovery_watcher is not None:
|
||||||
|
|||||||
@@ -152,6 +152,50 @@
|
|||||||
border-left: 1px solid var(--border-color);
|
border-left: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Weekday + timezone scheduling (time_of_day rule) */
|
||||||
|
.rule-weekday-block,
|
||||||
|
.rule-tz-block {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.rule-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.weekday-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.weekday-chip {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.weekday-chip:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.weekday-chip.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.rule-tz-block input.rule-timezone {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.time-range-label {
|
.time-range-label {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -79,6 +79,12 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-screen-total:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-screen-total.mismatch {
|
.preview-screen-total.mismatch {
|
||||||
color: #FFC107;
|
color: #FFC107;
|
||||||
}
|
}
|
||||||
@@ -123,6 +129,12 @@
|
|||||||
background: rgba(128, 128, 128, 0.25);
|
background: rgba(128, 128, 128, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edge-toggle:focus-visible {
|
||||||
|
background: rgba(128, 128, 128, 0.25);
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-edge.edge-disabled {
|
.preview-edge.edge-disabled {
|
||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -374,6 +386,13 @@
|
|||||||
color: rgba(76, 175, 80, 0.6);
|
color: rgba(76, 175, 80, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-corner:focus-visible {
|
||||||
|
color: rgba(76, 175, 80, 0.6);
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-corner.active:hover {
|
.preview-corner.active:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
@@ -412,6 +431,12 @@
|
|||||||
background: rgba(255, 255, 255, 0.25);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.direction-toggle:focus-visible {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.direction-toggle #direction-icon {
|
.direction-toggle #direction-icon {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2512,7 +2512,11 @@
|
|||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
/* `.modal-error` is the convention recommended in contexts/frontend.md and is
|
||||||
|
used by several modals; it aliases `.error-message` so both render the same
|
||||||
|
inline error banner. */
|
||||||
|
.error-message,
|
||||||
|
.modal-error {
|
||||||
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
|
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
|
||||||
border: 1px solid var(--danger-color);
|
border: 1px solid var(--danger-color);
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
|
|||||||
@@ -36,7 +36,16 @@ import {
|
|||||||
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
|
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
|
||||||
startIntegrationsTutorial,
|
startIntegrationsTutorial,
|
||||||
closeTutorial, tutorialNext, tutorialPrev,
|
closeTutorial, tutorialNext, tutorialPrev,
|
||||||
|
TOUR_KEY,
|
||||||
} from './features/tutorials.ts';
|
} from './features/tutorials.ts';
|
||||||
|
import {
|
||||||
|
openSetupWizard, closeSetupWizard,
|
||||||
|
checkAndOpenWizardIfNeeded,
|
||||||
|
wizardNext, wizardBack, wizardSkip, wizardFinish,
|
||||||
|
wizardShowManual, wizardHideManual, wizardRescan,
|
||||||
|
wizardSelectDiscovered, wizardAddManualDevice, wizardUseExistingDevice,
|
||||||
|
wizardSelectDisplay,
|
||||||
|
} from './features/setup-wizard.ts';
|
||||||
|
|
||||||
// Layer 4: devices, dashboard, streams, pattern-templates, automations
|
// Layer 4: devices, dashboard, streams, pattern-templates, automations
|
||||||
import {
|
import {
|
||||||
@@ -116,6 +125,11 @@ import {
|
|||||||
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
|
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
|
||||||
addSceneTarget,
|
addSceneTarget,
|
||||||
} from './features/scene-presets.ts';
|
} from './features/scene-presets.ts';
|
||||||
|
import {
|
||||||
|
openPlaylistEditor, editPlaylist, savePlaylist, closePlaylistEditor,
|
||||||
|
clonePlaylist, deletePlaylist, addPlaylistItem,
|
||||||
|
startScenePlaylist, stopScenePlaylist,
|
||||||
|
} from './features/scene-playlists.ts';
|
||||||
|
|
||||||
// Layer 5: device-discovery, targets
|
// Layer 5: device-discovery, targets
|
||||||
import {
|
import {
|
||||||
@@ -198,12 +212,21 @@ import {
|
|||||||
updateOffsetSkipLock, updateCalibrationPreview,
|
updateOffsetSkipLock, updateCalibrationPreview,
|
||||||
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
|
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
|
||||||
showCSSCalibration, toggleCalibrationOverlay,
|
showCSSCalibration, toggleCalibrationOverlay,
|
||||||
|
openAutoCalFromCalibration,
|
||||||
} from './features/calibration.ts';
|
} from './features/calibration.ts';
|
||||||
import {
|
import {
|
||||||
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
|
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
|
||||||
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
|
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
|
||||||
updateCalibrationLine, resetCalibrationView,
|
updateCalibrationLine, resetCalibrationView,
|
||||||
} from './features/advanced-calibration.ts';
|
} from './features/advanced-calibration.ts';
|
||||||
|
import {
|
||||||
|
showAutoCalibration, closeAutoCalModal,
|
||||||
|
autoCalSelectDevice, autoCalSetCorner, autoCalSetDirection,
|
||||||
|
autoCalBackToCorner, autoCalBackToDirection,
|
||||||
|
autoCalSweepForward, autoCalSweepBack, autoCalMarkCorner,
|
||||||
|
autoCalSolve, autoCalSave, autoCalCancel,
|
||||||
|
mountAutoCalibration, unmountAutoCalibration,
|
||||||
|
} from './features/auto-calibration.ts';
|
||||||
|
|
||||||
// Layer 5.5: graph editor
|
// Layer 5.5: graph editor
|
||||||
import {
|
import {
|
||||||
@@ -315,6 +338,21 @@ Object.assign(window, {
|
|||||||
selectDisplay,
|
selectDisplay,
|
||||||
formatDisplayLabel,
|
formatDisplayLabel,
|
||||||
|
|
||||||
|
// setup wizard
|
||||||
|
openSetupWizard,
|
||||||
|
closeSetupWizard,
|
||||||
|
wizardNext,
|
||||||
|
wizardBack,
|
||||||
|
wizardSkip,
|
||||||
|
wizardFinish,
|
||||||
|
wizardShowManual,
|
||||||
|
wizardHideManual,
|
||||||
|
wizardRescan,
|
||||||
|
wizardSelectDiscovered,
|
||||||
|
wizardAddManualDevice,
|
||||||
|
wizardUseExistingDevice,
|
||||||
|
wizardSelectDisplay,
|
||||||
|
|
||||||
// tutorials
|
// tutorials
|
||||||
startCalibrationTutorial,
|
startCalibrationTutorial,
|
||||||
startDeviceTutorial,
|
startDeviceTutorial,
|
||||||
@@ -463,6 +501,17 @@ Object.assign(window, {
|
|||||||
recaptureScenePreset,
|
recaptureScenePreset,
|
||||||
addSceneTarget,
|
addSceneTarget,
|
||||||
|
|
||||||
|
// scene playlists — modal buttons + mod-card inline handlers
|
||||||
|
openPlaylistEditor,
|
||||||
|
editPlaylist,
|
||||||
|
savePlaylist,
|
||||||
|
closePlaylistEditor,
|
||||||
|
clonePlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
addPlaylistItem,
|
||||||
|
startScenePlaylist,
|
||||||
|
stopScenePlaylist,
|
||||||
|
|
||||||
// integrations
|
// integrations
|
||||||
loadIntegrations,
|
loadIntegrations,
|
||||||
switchIntegrationTab,
|
switchIntegrationTab,
|
||||||
@@ -604,6 +653,24 @@ Object.assign(window, {
|
|||||||
toggleTestEdge,
|
toggleTestEdge,
|
||||||
showCSSCalibration,
|
showCSSCalibration,
|
||||||
toggleCalibrationOverlay,
|
toggleCalibrationOverlay,
|
||||||
|
openAutoCalFromCalibration,
|
||||||
|
|
||||||
|
// auto-calibration wizard
|
||||||
|
showAutoCalibration,
|
||||||
|
closeAutoCalModal,
|
||||||
|
autoCalSelectDevice,
|
||||||
|
autoCalSetCorner,
|
||||||
|
autoCalSetDirection,
|
||||||
|
autoCalBackToCorner,
|
||||||
|
autoCalBackToDirection,
|
||||||
|
autoCalSweepForward,
|
||||||
|
autoCalSweepBack,
|
||||||
|
autoCalMarkCorner,
|
||||||
|
autoCalSolve,
|
||||||
|
autoCalSave,
|
||||||
|
autoCalCancel,
|
||||||
|
mountAutoCalibration,
|
||||||
|
unmountAutoCalibration,
|
||||||
|
|
||||||
// advanced calibration
|
// advanced calibration
|
||||||
showAdvancedCalibration,
|
showAdvancedCalibration,
|
||||||
@@ -908,8 +975,17 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
setProjectUrls(serverRepoUrl, serverDonateUrl);
|
setProjectUrls(serverRepoUrl, serverDonateUrl);
|
||||||
initDonationBanner();
|
initDonationBanner();
|
||||||
|
|
||||||
// Show getting-started tutorial on first visit
|
// First-run: wizard wins over the tooltip tour.
|
||||||
if (!localStorage.getItem('tour_completed')) {
|
//
|
||||||
|
// Precedence (explicit):
|
||||||
|
// 1. If backend says onboarded=false AND no output targets exist
|
||||||
|
// → open the setup wizard (suppresses tooltip tour — wizard owns
|
||||||
|
// the first-run experience; it sets localStorage TOUR_KEY on
|
||||||
|
// completion/skip so the tour never double-fires on reload).
|
||||||
|
// 2. Otherwise (already onboarded, or has targets but no wizard flag)
|
||||||
|
// → fall back to the existing tooltip tour logic unchanged.
|
||||||
|
const wizardOpened = await checkAndOpenWizardIfNeeded();
|
||||||
|
if (!wizardOpened && !localStorage.getItem(TOUR_KEY)) {
|
||||||
setTimeout(() => startGettingStartedTutorial(), 600);
|
setTimeout(() => startGettingStartedTutorial(), 600);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import {
|
import {
|
||||||
devicesCache, outputTargetsCache, colorStripSourcesCache,
|
devicesCache, outputTargetsCache, colorStripSourcesCache,
|
||||||
streamsCache, audioSourcesCache, valueSourcesCache,
|
streamsCache, audioSourcesCache, valueSourcesCache,
|
||||||
syncClocksCache, automationsCacheObj, scenePresetsCache,
|
syncClocksCache, automationsCacheObj, scenePresetsCache, scenePlaylistsCache,
|
||||||
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
|
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
|
||||||
patternTemplatesCache,
|
patternTemplatesCache,
|
||||||
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
|
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
|
||||||
@@ -26,6 +26,7 @@ const ENTITY_CACHE_MAP = {
|
|||||||
sync_clock: syncClocksCache,
|
sync_clock: syncClocksCache,
|
||||||
automation: automationsCacheObj,
|
automation: automationsCacheObj,
|
||||||
scene_preset: scenePresetsCache,
|
scene_preset: scenePresetsCache,
|
||||||
|
scene_playlist: scenePlaylistsCache,
|
||||||
capture_template: captureTemplatesCache,
|
capture_template: captureTemplatesCache,
|
||||||
audio_template: audioTemplatesCache,
|
audio_template: audioTemplatesCache,
|
||||||
pp_template: ppTemplatesCache,
|
pp_template: ppTemplatesCache,
|
||||||
@@ -51,6 +52,7 @@ const ENTITY_LOADER_MAP = {
|
|||||||
pp_template: 'loadPictureSources',
|
pp_template: 'loadPictureSources',
|
||||||
automation: 'loadAutomations',
|
automation: 'loadAutomations',
|
||||||
scene_preset: 'loadAutomations',
|
scene_preset: 'loadAutomations',
|
||||||
|
scene_playlist: 'loadAutomations',
|
||||||
weather_source: 'loadIntegrations',
|
weather_source: 'loadIntegrations',
|
||||||
home_assistant_source: 'loadIntegrations',
|
home_assistant_source: 'loadIntegrations',
|
||||||
mqtt_source: 'loadIntegrations',
|
mqtt_source: 'loadIntegrations',
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|||||||
'server_restarting',
|
'server_restarting',
|
||||||
'state_change',
|
'state_change',
|
||||||
'automation_state_changed',
|
'automation_state_changed',
|
||||||
|
'playlist_state_changed',
|
||||||
'entity_changed',
|
'entity_changed',
|
||||||
'device_health_changed',
|
'device_health_changed',
|
||||||
'update_available',
|
'update_available',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import { DataCache } from './cache.ts';
|
import { DataCache } from './cache.ts';
|
||||||
import type {
|
import type {
|
||||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
ValueSource, AudioSource, PictureSource, ScenePreset, ScenePlaylist,
|
||||||
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
|
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||||
@@ -436,6 +436,11 @@ export const scenePresetsCache = new DataCache<ScenePreset[]>({
|
|||||||
extractData: json => json.presets || [],
|
extractData: json => json.presets || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const scenePlaylistsCache = new DataCache<ScenePlaylist[]>({
|
||||||
|
endpoint: '/scene-playlists',
|
||||||
|
extractData: json => json.playlists || [],
|
||||||
|
});
|
||||||
|
|
||||||
export interface GradientEntity {
|
export interface GradientEntity {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,840 @@
|
|||||||
|
/**
|
||||||
|
* Auto-Calibration flow — guided LED-chase corner-tap wizard.
|
||||||
|
*
|
||||||
|
* Exports `mountAutoCalibration` / `unmountAutoCalibration` so Phase 4's
|
||||||
|
* wizard can embed this as a step without modification.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Device selection (EntitySelect; skipped when deviceId supplied)
|
||||||
|
* 2. Start corner — light index 0; user taps which corner is lit → start_position
|
||||||
|
* 3. Direction — advance a few indices; user identifies direction → layout
|
||||||
|
* 4. Tap-to-mark-corners — dot sweeps; user taps NEXT at each physical corner
|
||||||
|
* (first tap = corner at index 0, per Phase 1 solver contract)
|
||||||
|
* 5. Preview & Save — POST /calibration/solve → summary → PUT CSS hot-reload
|
||||||
|
*
|
||||||
|
* Session contract (Phase 1 handoff):
|
||||||
|
* POST /api/v1/calibration/session → start (stops running target)
|
||||||
|
* POST /api/v1/calibration/session/position → advance chase pixel
|
||||||
|
* POST /api/v1/calibration/session/stop → ALWAYS call on exit / error
|
||||||
|
* POST /api/v1/calibration/solve → pure solver (no persist)
|
||||||
|
* PUT /api/v1/color-strip-sources/{id} → persist + hot-reload
|
||||||
|
*
|
||||||
|
* CRITICAL: the first corner tap corresponds to LED index 0 so the solver's
|
||||||
|
* `corner_indices[0] == 0` matches `solve_calibration`'s assumption that the
|
||||||
|
* start corner is at strip index 0 (Phase 1 review finding).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiPost, apiPut } from '../core/api-client.ts';
|
||||||
|
import { colorStripSourcesCache, devicesCache } from '../core/state.ts';
|
||||||
|
import { t } from '../core/i18n.ts';
|
||||||
|
import { showToast } from '../core/ui.ts';
|
||||||
|
import { Modal } from '../core/modal.ts';
|
||||||
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
|
import { renderDeviceIcon } from '../core/device-icons.ts';
|
||||||
|
import {
|
||||||
|
ICON_DEVICE, ICON_ROTATE_CW, ICON_ROTATE_CCW,
|
||||||
|
ICON_CALIBRATION, ICON_OK,
|
||||||
|
} from '../core/icons.ts';
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type StartPosition = 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right';
|
||||||
|
type Layout = 'clockwise' | 'counterclockwise';
|
||||||
|
type AutoCalStep = 'device' | 'corner' | 'direction' | 'corners' | 'preview';
|
||||||
|
|
||||||
|
interface CalibrationSessionState {
|
||||||
|
active: boolean;
|
||||||
|
device_id: string | null;
|
||||||
|
led_count: number;
|
||||||
|
prior_target_id: string | null;
|
||||||
|
last_activity: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SolvedCalibration {
|
||||||
|
mode: 'simple';
|
||||||
|
layout: string;
|
||||||
|
start_position: string;
|
||||||
|
leds_top: number;
|
||||||
|
leds_right: number;
|
||||||
|
leds_bottom: number;
|
||||||
|
leds_left: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoCalState {
|
||||||
|
step: AutoCalStep;
|
||||||
|
cssId: string;
|
||||||
|
cssSourceType: string;
|
||||||
|
deviceId: string;
|
||||||
|
ledCount: number;
|
||||||
|
startPosition: StartPosition | null;
|
||||||
|
layout: Layout | null;
|
||||||
|
/** Strip indices of the 4 physical corners, in strip-walk order.
|
||||||
|
* cornerIndices[0] is ALWAYS 0 (start corner = LED index 0). */
|
||||||
|
cornerIndices: number[];
|
||||||
|
currentIndex: number;
|
||||||
|
sessionActive: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
solved: SolvedCalibration | null;
|
||||||
|
errorMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `mountAutoCalibration()`. */
|
||||||
|
export interface AutoCalOptions {
|
||||||
|
/** DOM container element to render wizard steps into. */
|
||||||
|
container: HTMLElement;
|
||||||
|
/** Color-strip source ID being calibrated. */
|
||||||
|
cssId: string;
|
||||||
|
/** Pre-selected device ID; if supplied the device-picker step is skipped. */
|
||||||
|
deviceId?: string;
|
||||||
|
/** Called after successful save. */
|
||||||
|
onComplete?: () => void;
|
||||||
|
/** Called after user cancels (session already stopped before this fires). */
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Module-level singleton ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _state: AutoCalState | null = null;
|
||||||
|
let _opts: AutoCalOptions | null = null;
|
||||||
|
let _deviceEntitySelect: EntitySelect | null = null;
|
||||||
|
/** First render after mount: let the containing Modal's autofocus win; only
|
||||||
|
* steal focus on subsequent step transitions (for D-pad / TV navigation). */
|
||||||
|
let _firstRender = true;
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the auto-calibration flow into the given container.
|
||||||
|
*
|
||||||
|
* Phase 4 usage:
|
||||||
|
* ```ts
|
||||||
|
* await mountAutoCalibration({
|
||||||
|
* container: document.getElementById('wizard-body')!,
|
||||||
|
* cssId: sourceId,
|
||||||
|
* deviceId: inferredDeviceId, // optional
|
||||||
|
* onComplete: () => wizard.next(),
|
||||||
|
* onCancel: () => wizard.close(),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
* Call `unmountAutoCalibration()` when the containing modal closes to guarantee
|
||||||
|
* the calibration session is stopped.
|
||||||
|
*/
|
||||||
|
export async function mountAutoCalibration(opts: AutoCalOptions): Promise<void> {
|
||||||
|
await unmountAutoCalibration();
|
||||||
|
_firstRender = true;
|
||||||
|
_opts = opts;
|
||||||
|
|
||||||
|
let cssSourceType = 'picture';
|
||||||
|
try {
|
||||||
|
const sources = await colorStripSourcesCache.fetch() as { id: string; source_type?: string }[];
|
||||||
|
const src = sources.find(s => s.id === opts.cssId);
|
||||||
|
if (src) cssSourceType = src.source_type || 'picture';
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
|
||||||
|
_state = {
|
||||||
|
step: opts.deviceId ? 'corner' : 'device',
|
||||||
|
cssId: opts.cssId,
|
||||||
|
cssSourceType,
|
||||||
|
deviceId: opts.deviceId || '',
|
||||||
|
ledCount: 0,
|
||||||
|
startPosition: null,
|
||||||
|
layout: null,
|
||||||
|
cornerIndices: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
sessionActive: false,
|
||||||
|
busy: false,
|
||||||
|
solved: null,
|
||||||
|
errorMsg: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
_render();
|
||||||
|
|
||||||
|
if (opts.deviceId) {
|
||||||
|
_state.deviceId = opts.deviceId;
|
||||||
|
await _startSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmount: stop any active session, destroy widgets, clear container.
|
||||||
|
* Safe to call when nothing is mounted.
|
||||||
|
*/
|
||||||
|
export async function unmountAutoCalibration(): Promise<void> {
|
||||||
|
if (_deviceEntitySelect) { _deviceEntitySelect.destroy(); _deviceEntitySelect = null; }
|
||||||
|
if (_state?.sessionActive) {
|
||||||
|
await _stopSession().catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
if (_opts?.container) _opts.container.innerHTML = '';
|
||||||
|
_state = null;
|
||||||
|
_opts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal render ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _render(): void {
|
||||||
|
if (!_opts || !_state) return;
|
||||||
|
switch (_state.step) {
|
||||||
|
case 'device': _renderDevice(); break;
|
||||||
|
case 'corner': _renderCorner(); break;
|
||||||
|
case 'direction': _renderDirection(); break;
|
||||||
|
case 'corners': _renderCorners(); break;
|
||||||
|
case 'preview': _renderPreview(); break;
|
||||||
|
}
|
||||||
|
_focusFirstControl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move focus to the step's first focusable control after a re-render so D-pad /
|
||||||
|
* TV WebView users don't land on a node that was just removed. Skipped on the
|
||||||
|
* very first render so it doesn't fight the Modal's own initial autofocus.
|
||||||
|
*/
|
||||||
|
function _focusFirstControl(): void {
|
||||||
|
if (_firstRender) {
|
||||||
|
_firstRender = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = _opts?.container;
|
||||||
|
if (!container) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = container.querySelector(
|
||||||
|
'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])'
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (el && el.offsetParent !== null) el.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: Device picker ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderDevice(): void {
|
||||||
|
if (!_opts) return;
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="device">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_DEVICE}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.device.title'))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.device.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:16px;">
|
||||||
|
<label for="autocal-device-select">${_esc(t('autocal.device.label'))}</label>
|
||||||
|
<select id="autocal-device-select"></select>
|
||||||
|
</div>
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-primary" onclick="autoCalSelectDevice()">${_esc(t('autocal.btn.next'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_populateDeviceSelect();
|
||||||
|
_showError(_state?.errorMsg || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _populateDeviceSelect(): Promise<void> {
|
||||||
|
const sel = document.getElementById('autocal-device-select') as HTMLSelectElement | null;
|
||||||
|
if (!sel) return;
|
||||||
|
let devices: { id: string; name: string; led_count: number; icon?: string }[] = [];
|
||||||
|
try { devices = await devicesCache.fetch() as typeof devices; } catch { /* empty */ }
|
||||||
|
|
||||||
|
sel.innerHTML = '';
|
||||||
|
devices.forEach(d => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = d.id;
|
||||||
|
opt.textContent = d.name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_deviceEntitySelect) { _deviceEntitySelect.destroy(); _deviceEntitySelect = null; }
|
||||||
|
if (devices.length > 0) {
|
||||||
|
_deviceEntitySelect = new EntitySelect({
|
||||||
|
target: sel,
|
||||||
|
getItems: () => devices.map(d => ({
|
||||||
|
value: d.id,
|
||||||
|
label: d.name,
|
||||||
|
icon: renderDeviceIcon(d.icon) || ICON_DEVICE,
|
||||||
|
desc: d.led_count ? `${d.led_count} LEDs` : '',
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
} as ConstructorParameters<typeof EntitySelect>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select LED-count-matched device
|
||||||
|
if (devices.length > 0 && _state) {
|
||||||
|
try {
|
||||||
|
const sources = await colorStripSourcesCache.fetch() as { id: string; led_count?: number }[];
|
||||||
|
const src = sources.find(s => s.id === _state!.cssId);
|
||||||
|
if (src?.led_count) {
|
||||||
|
const match = devices.find(d => d.led_count === src.led_count);
|
||||||
|
if (match) {
|
||||||
|
sel.value = match.id;
|
||||||
|
if (_deviceEntitySelect) _deviceEntitySelect.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSelectDevice(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const sel = document.getElementById('autocal-device-select') as HTMLSelectElement | null;
|
||||||
|
if (!sel?.value) { _setError(t('autocal.error.no_device')); return; }
|
||||||
|
_state.deviceId = sel.value;
|
||||||
|
_state.step = 'corner';
|
||||||
|
_render();
|
||||||
|
await _startSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: Start corner ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderCorner(): void {
|
||||||
|
if (!_opts) return;
|
||||||
|
const busy = _state?.busy ?? false;
|
||||||
|
const s = _state!;
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="corner">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_CALIBRATION}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.corner.title'))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.corner.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-led-indicator">
|
||||||
|
<span class="autocal-led-dot ${busy ? '' : 'autocal-led-dot--active'}" aria-hidden="true"></span>
|
||||||
|
<span class="autocal-led-index">${_esc(t('autocal.corner.led_index', { index: '0' }))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-corner-grid" ${busy ? 'aria-busy="true"' : ''}>
|
||||||
|
${(['top_left', 'top_right', 'bottom_left', 'bottom_right'] as StartPosition[]).map(pos =>
|
||||||
|
`<button class="autocal-corner-btn autocal-corner-btn--${pos.replace('_', '-')}"
|
||||||
|
onclick="autoCalSetCorner('${pos}')"
|
||||||
|
${busy ? 'disabled' : ''}
|
||||||
|
aria-label="${_esc(t(`autocal.position.${pos}`))}">
|
||||||
|
<span class="autocal-corner-glyph" aria-hidden="true"></span>
|
||||||
|
<span>${_esc(t(`autocal.position.${pos}`))}</span>
|
||||||
|
</button>`
|
||||||
|
).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(s.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSetCorner(position: StartPosition): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.startPosition = position;
|
||||||
|
_state.step = 'direction';
|
||||||
|
_state.busy = true;
|
||||||
|
_render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// LED is at index 0; advance to ~5% to show movement direction
|
||||||
|
await _setPosition(0);
|
||||||
|
await _delay(350);
|
||||||
|
// User may have cancelled during the delay (unmount sets _state = null).
|
||||||
|
if (!_state) return;
|
||||||
|
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
|
||||||
|
await _setPosition(advance);
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = false;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// _state may have been torn down mid-await by a cancel.
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = false;
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
_state.step = 'corner'; // revert on error
|
||||||
|
}
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: Direction ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderDirection(): void {
|
||||||
|
if (!_opts || !_state) return;
|
||||||
|
const busy = _state.busy;
|
||||||
|
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="direction">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_ROTATE_CW}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.direction.title', { step: String(advance) }))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.direction.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-direction-grid">
|
||||||
|
<button class="autocal-direction-btn" onclick="autoCalSetDirection('clockwise')" ${busy ? 'disabled' : ''}>
|
||||||
|
${ICON_ROTATE_CW}
|
||||||
|
<span>${_esc(t('calibration.direction.clockwise'))}</span>
|
||||||
|
</button>
|
||||||
|
<button class="autocal-direction-btn" onclick="autoCalSetDirection('counterclockwise')" ${busy ? 'disabled' : ''}>
|
||||||
|
${ICON_ROTATE_CCW}
|
||||||
|
<span>${_esc(t('calibration.direction.counterclockwise'))}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-ghost" onclick="autoCalBackToCorner()">${_esc(t('autocal.btn.back'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSetDirection(layout: Layout): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.layout = layout;
|
||||||
|
// corner_indices[0] MUST be 0 (Phase 1 solver contract: start corner = index 0)
|
||||||
|
_state.cornerIndices = [0];
|
||||||
|
_state.currentIndex = 0;
|
||||||
|
_state.step = 'corners';
|
||||||
|
_render();
|
||||||
|
await _setPosition(0).catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalBackToCorner(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.step = 'corner';
|
||||||
|
_state.startPosition = null;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
await _setPosition(0).catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: Tap-to-mark corners ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderCorners(): void {
|
||||||
|
if (!_opts || !_state) return;
|
||||||
|
const { cornerIndices, currentIndex, ledCount, busy } = _state;
|
||||||
|
const collected = cornerIndices.length; // starts at 1 (index 0 already in)
|
||||||
|
const isComplete = collected >= 4;
|
||||||
|
const cornerLabels = _cornerLabels(_state.startPosition!, _state.layout!);
|
||||||
|
|
||||||
|
const pips = [0, 1, 2, 3].map(i => {
|
||||||
|
const done = i < collected;
|
||||||
|
const active = i === collected - 1;
|
||||||
|
return `<span class="autocal-pip ${done ? 'autocal-pip--done' : ''} ${active ? 'autocal-pip--active' : ''}"
|
||||||
|
aria-label="${cornerLabels[i]}">${i + 1}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const activeCornerLabel = isComplete ? '' : cornerLabels[collected - 1];
|
||||||
|
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="corners">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_CALIBRATION}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(isComplete ? t('autocal.corners.title', { remaining: '0' }) : t('autocal.corners.title', { remaining: String(4 - collected) }))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(
|
||||||
|
isComplete
|
||||||
|
? t('autocal.corners.desc_complete')
|
||||||
|
: t('autocal.corners.desc', { corner: activeCornerLabel })
|
||||||
|
)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autocal-corners-progress">
|
||||||
|
<div class="autocal-pips">${pips}</div>
|
||||||
|
<div class="autocal-index-badge">
|
||||||
|
<span class="autocal-index-label">${_esc(t('autocal.corners.index_label'))}</span>
|
||||||
|
<span class="autocal-index-value">${currentIndex}</span>
|
||||||
|
<span class="autocal-index-total">/ ${ledCount - 1}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autocal-sweep-row">
|
||||||
|
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepBack()" ${busy || isComplete || currentIndex <= 0 ? 'disabled' : ''}
|
||||||
|
aria-label="${_esc(t('autocal.btn.step_back'))}">←</button>
|
||||||
|
<div class="autocal-led-track">
|
||||||
|
<div class="autocal-led-track-fill" style="width:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
|
||||||
|
<div class="autocal-led-cursor" style="left:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepForward()" ${busy || isComplete || currentIndex >= ledCount - 1 ? 'disabled' : ''}
|
||||||
|
aria-label="${_esc(t('autocal.btn.step_fwd'))}">→</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${isComplete ? '' : `
|
||||||
|
<button class="btn btn-primary autocal-mark-btn" onclick="autoCalMarkCorner()" ${busy ? 'disabled' : ''}>
|
||||||
|
${_esc(t('autocal.btn.mark_corner', { n: String(collected), label: activeCornerLabel }))}
|
||||||
|
</button>`}
|
||||||
|
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-ghost" onclick="autoCalBackToDirection()">${_esc(t('autocal.btn.back'))}</button>
|
||||||
|
${isComplete ? `<button class="btn btn-primary" onclick="autoCalSolve()">${_esc(t('autocal.btn.solve'))}</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cornerLabels(startPos: StartPosition, layout: Layout): string[] {
|
||||||
|
const all: StartPosition[] = ['top_left', 'top_right', 'bottom_right', 'bottom_left'];
|
||||||
|
const si = all.indexOf(startPos);
|
||||||
|
let ordered: StartPosition[];
|
||||||
|
if (layout === 'clockwise') {
|
||||||
|
ordered = [all[si % 4], all[(si + 1) % 4], all[(si + 2) % 4], all[(si + 3) % 4]];
|
||||||
|
} else {
|
||||||
|
ordered = [all[si % 4], all[(si + 3) % 4], all[(si + 2) % 4], all[(si + 1) % 4]];
|
||||||
|
}
|
||||||
|
return ordered.map(c => t(`autocal.position.${c}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSweepForward(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||||
|
const next = _state.currentIndex + 1;
|
||||||
|
if (next >= _state.ledCount) return;
|
||||||
|
_state.busy = true;
|
||||||
|
try {
|
||||||
|
await _setPosition(next);
|
||||||
|
_state.currentIndex = next;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSweepBack(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||||
|
const prev = _state.currentIndex - 1;
|
||||||
|
// Clamp to one past the last marked corner index to preserve monotonic ordering.
|
||||||
|
const lastMarked = _state.cornerIndices.length > 0
|
||||||
|
? _state.cornerIndices[_state.cornerIndices.length - 1]
|
||||||
|
: -1;
|
||||||
|
if (prev < 0 || prev <= lastMarked) return;
|
||||||
|
_state.busy = true;
|
||||||
|
try {
|
||||||
|
await _setPosition(prev);
|
||||||
|
_state.currentIndex = prev;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalMarkCorner(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||||
|
_state.cornerIndices.push(_state.currentIndex);
|
||||||
|
if (_state.cornerIndices.length < 4) {
|
||||||
|
// Nudge forward so user can see the dot isn't stuck
|
||||||
|
const next = Math.min(_state.currentIndex + 1, _state.ledCount - 1);
|
||||||
|
_state.busy = true;
|
||||||
|
try {
|
||||||
|
await _setPosition(next);
|
||||||
|
_state.currentIndex = next;
|
||||||
|
} catch { /* best effort */ } finally {
|
||||||
|
_state.busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalBackToDirection(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.step = 'direction';
|
||||||
|
_state.layout = null;
|
||||||
|
_state.cornerIndices = [];
|
||||||
|
_state.currentIndex = 0;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
await _setPosition(0).catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSolve(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length !== 4) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const solved = await apiPost<SolvedCalibration>('/calibration/solve', {
|
||||||
|
device_id: _state.deviceId,
|
||||||
|
start_position: _state.startPosition,
|
||||||
|
layout: _state.layout,
|
||||||
|
corner_indices: _state.cornerIndices,
|
||||||
|
offset: 0,
|
||||||
|
}, { errorMessage: t('autocal.error.solve_failed') });
|
||||||
|
|
||||||
|
_state.solved = solved;
|
||||||
|
// Stop the chase session — device restored to prior target
|
||||||
|
await _stopSession();
|
||||||
|
_state.step = 'preview';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: Preview & Save ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderPreview(): void {
|
||||||
|
if (!_opts || !_state?.solved) return;
|
||||||
|
const s = _state.solved;
|
||||||
|
const busy = _state.busy;
|
||||||
|
|
||||||
|
const dirLabel = s.layout === 'clockwise'
|
||||||
|
? t('calibration.direction.clockwise')
|
||||||
|
: t('calibration.direction.counterclockwise');
|
||||||
|
|
||||||
|
const dirIcon = s.layout === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW;
|
||||||
|
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="preview">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon autocal-step-icon--ok">${ICON_OK}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.preview.title'))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.preview.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autocal-solved-grid">
|
||||||
|
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.start'))}</span>
|
||||||
|
<span class="autocal-solved-val">${_esc(t(`autocal.position.${s.start_position}`))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('calibration.direction'))}</span>
|
||||||
|
<span class="autocal-solved-val">${dirIcon} ${_esc(dirLabel)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.top'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_top}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.right'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_right}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.bottom'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_bottom}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.left'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_left}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.total'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_top + s.leds_right + s.leds_bottom + s.leds_left}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-primary" id="autocal-save-btn" onclick="autoCalSave()" ${busy ? 'disabled' : ''}>
|
||||||
|
${_esc(t('autocal.btn.save'))}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSave(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || !_state.solved) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
const btn = document.getElementById('autocal-save-btn');
|
||||||
|
if (btn) btn.setAttribute('disabled', 'true');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s = _state.solved;
|
||||||
|
await apiPut(`/color-strip-sources/${_state.cssId}`, {
|
||||||
|
source_type: _state.cssSourceType,
|
||||||
|
calibration: {
|
||||||
|
mode: 'simple',
|
||||||
|
layout: s.layout,
|
||||||
|
start_position: s.start_position,
|
||||||
|
leds_top: s.leds_top,
|
||||||
|
leds_right: s.leds_right,
|
||||||
|
leds_bottom: s.leds_bottom,
|
||||||
|
leds_left: s.leds_left,
|
||||||
|
offset: s.offset,
|
||||||
|
span_top_start: 0, span_top_end: 1,
|
||||||
|
span_right_start: 0, span_right_end: 1,
|
||||||
|
span_bottom_start: 0, span_bottom_end: 1,
|
||||||
|
span_left_start: 0, span_left_end: 1,
|
||||||
|
skip_leds_start: 0,
|
||||||
|
skip_leds_end: 0,
|
||||||
|
border_width: 10,
|
||||||
|
roi_x: 0, roi_y: 0, roi_width: 1, roi_height: 1,
|
||||||
|
},
|
||||||
|
}, { errorMessage: t('autocal.error.save_failed') });
|
||||||
|
|
||||||
|
colorStripSourcesCache.invalidate();
|
||||||
|
showToast(t('autocal.saved'), 'success');
|
||||||
|
|
||||||
|
const onComplete = _opts?.onComplete;
|
||||||
|
await unmountAutoCalibration();
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
if (btn) btn.removeAttribute('disabled');
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cancel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function autoCalCancel(): Promise<void> {
|
||||||
|
const onCancel = _opts?.onCancel;
|
||||||
|
await unmountAutoCalibration();
|
||||||
|
if (onCancel) onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session lifecycle ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _startSession(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_render();
|
||||||
|
try {
|
||||||
|
const state = await apiPost<CalibrationSessionState>('/calibration/session', {
|
||||||
|
device_id: _state.deviceId,
|
||||||
|
}, { errorMessage: t('autocal.error.session_start_failed') });
|
||||||
|
_state.sessionActive = true;
|
||||||
|
_state.ledCount = state.led_count;
|
||||||
|
_state.busy = false;
|
||||||
|
await _setPosition(0);
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Session may already be live (POST /calibration/session succeeded before _setPosition threw),
|
||||||
|
// so call _stopSession() to let the backend tear down cleanly instead of flipping the flag directly.
|
||||||
|
await _stopSession().catch(() => { /* best effort */ });
|
||||||
|
_state.busy = false;
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _stopSession(): Promise<void> {
|
||||||
|
if (!_state?.sessionActive) return;
|
||||||
|
try {
|
||||||
|
await apiPost<CalibrationSessionState>('/calibration/session/stop', undefined, {
|
||||||
|
errorMessage: t('autocal.error.session_stop_failed'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (_state) _state.sessionActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _setPosition(index: number): Promise<void> {
|
||||||
|
if (!_state?.sessionActive) return;
|
||||||
|
await apiPost<CalibrationSessionState>('/calibration/session/position', {
|
||||||
|
index,
|
||||||
|
window: 1,
|
||||||
|
}, { errorMessage: t('autocal.error.position_failed') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _errMsg(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showError(msg: string): void {
|
||||||
|
const el = document.getElementById('autocal-error');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = msg ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setError(msg: string): void {
|
||||||
|
if (_state) _state.errorMsg = msg;
|
||||||
|
_showError(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Standalone modal management ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The standalone modal is the Phase 3 surface: opened from the calibration
|
||||||
|
// modal's "Auto-calibrate" button. Phase 4 wizard uses mountAutoCalibration()
|
||||||
|
// directly (no modal wrapper needed — the wizard is itself a modal).
|
||||||
|
|
||||||
|
class AutoCalModal extends Modal {
|
||||||
|
constructor() { super('auto-calibration-modal'); }
|
||||||
|
|
||||||
|
snapshotValues(): Record<string, string> {
|
||||||
|
// No dirty-check needed for a wizard flow; always allow close.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
onForceClose(): void {
|
||||||
|
// Unmount the flow asynchronously (session stop is async)
|
||||||
|
unmountAutoCalibration().catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _autoCalModal = new AutoCalModal();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the auto-calibration wizard for a color-strip source.
|
||||||
|
*
|
||||||
|
* Called from calibration.ts "Auto-calibrate" button.
|
||||||
|
*
|
||||||
|
* @param cssId The color-strip source ID to calibrate.
|
||||||
|
* @param deviceId Optional pre-selected device; if omitted, the device picker
|
||||||
|
* step is shown.
|
||||||
|
*/
|
||||||
|
export async function showAutoCalibration(cssId: string, deviceId?: string): Promise<void> {
|
||||||
|
const container = document.getElementById('autocal-step-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Store context on the hidden inputs for reference
|
||||||
|
const cssIdInput = document.getElementById('autocal-modal-css-id') as HTMLInputElement | null;
|
||||||
|
const deviceIdInput = document.getElementById('autocal-modal-device-id') as HTMLInputElement | null;
|
||||||
|
if (cssIdInput) cssIdInput.value = cssId;
|
||||||
|
if (deviceIdInput) deviceIdInput.value = deviceId || '';
|
||||||
|
|
||||||
|
_autoCalModal.open();
|
||||||
|
_autoCalModal.snapshot();
|
||||||
|
|
||||||
|
await mountAutoCalibration({
|
||||||
|
container,
|
||||||
|
cssId,
|
||||||
|
deviceId,
|
||||||
|
onComplete: () => {
|
||||||
|
_autoCalModal.forceClose();
|
||||||
|
// Reload calibration view if open
|
||||||
|
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
_autoCalModal.forceClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the auto-calibration modal (stops session). */
|
||||||
|
export async function closeAutoCalModal(): Promise<void> {
|
||||||
|
await _autoCalModal.close();
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
|
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
|
||||||
scenePresetsCache, _cachedHASources, haSourcesCache,
|
scenePresetsCache, scenePlaylistsCache, _cachedHASources, haSourcesCache,
|
||||||
_cachedValueSources, valueSourcesCache,
|
_cachedValueSources, valueSourcesCache,
|
||||||
getHAEntityFriendlyName, setHAEntityNames,
|
getHAEntityFriendlyName, setHAEntityNames,
|
||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
@@ -18,7 +18,7 @@ import { Modal } from '../core/modal.ts';
|
|||||||
import { CardSection } from '../core/card-sections.ts';
|
import { CardSection } from '../core/card-sections.ts';
|
||||||
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
|
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
|
||||||
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||||
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts';
|
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE, ICON_LIST_CHECKS } from '../core/icons.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { wrapCard } from '../core/card-colors.ts';
|
import { wrapCard } from '../core/card-colors.ts';
|
||||||
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
||||||
@@ -32,6 +32,7 @@ import { enhanceMiniSelects } from '../core/mini-select.ts';
|
|||||||
import { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts';
|
import { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts';
|
||||||
import { TreeNav } from '../core/tree-nav.ts';
|
import { TreeNav } from '../core/tree-nav.ts';
|
||||||
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
||||||
|
import { csPlaylists, createPlaylistCard, initPlaylistDelegation } from './scene-playlists.ts';
|
||||||
import type { Automation, RuleType } from '../types.ts';
|
import type { Automation, RuleType } from '../types.ts';
|
||||||
|
|
||||||
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
|
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
|
||||||
@@ -252,6 +253,7 @@ export async function loadAutomations() {
|
|||||||
const [automations, scenes] = await Promise.all([
|
const [automations, scenes] = await Promise.all([
|
||||||
automationsCacheObj.fetch(),
|
automationsCacheObj.fetch(),
|
||||||
scenePresetsCache.fetch(),
|
scenePresetsCache.fetch(),
|
||||||
|
scenePlaylistsCache.fetch(),
|
||||||
haSourcesCache.fetch(),
|
haSourcesCache.fetch(),
|
||||||
valueSourcesCache.fetch(),
|
valueSourcesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
@@ -291,38 +293,45 @@ function renderAutomations(automations: any, sceneMap: any) {
|
|||||||
|
|
||||||
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
|
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
|
||||||
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
|
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
|
||||||
|
const playlistItems = csPlaylists.applySortOrder(scenePlaylistsCache.data.map(p => ({ key: p.id, html: createPlaylistCard(p) })));
|
||||||
|
|
||||||
const activeTab = getActiveSubTab('automations')!;
|
const activeTab = getActiveSubTab('automations')!;
|
||||||
|
|
||||||
const treeItems = [
|
const treeItems = [
|
||||||
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
|
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
|
||||||
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
|
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
|
||||||
|
{ key: 'playlists', icon: ICON_LIST_CHECKS, titleKey: 'playlists.title', count: scenePlaylistsCache.data.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (csAutomations.isMounted()) {
|
if (csAutomations.isMounted()) {
|
||||||
_automationsTree.updateCounts({
|
_automationsTree.updateCounts({
|
||||||
automations: automations.length,
|
automations: automations.length,
|
||||||
scenes: scenePresetsCache.data.length,
|
scenes: scenePresetsCache.data.length,
|
||||||
|
playlists: scenePlaylistsCache.data.length,
|
||||||
});
|
});
|
||||||
csAutomations.reconcile(autoItems);
|
csAutomations.reconcile(autoItems);
|
||||||
csScenes.reconcile(sceneItems);
|
csScenes.reconcile(sceneItems);
|
||||||
|
csPlaylists.reconcile(playlistItems);
|
||||||
} else {
|
} else {
|
||||||
const panels = [
|
const panels = [
|
||||||
{ key: 'automations', html: csAutomations.render(autoItems) },
|
{ key: 'automations', html: csAutomations.render(autoItems) },
|
||||||
{ key: 'scenes', html: csScenes.render(sceneItems) },
|
{ key: 'scenes', html: csScenes.render(sceneItems) },
|
||||||
|
{ key: 'playlists', html: csPlaylists.render(playlistItems) },
|
||||||
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
|
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
|
||||||
|
|
||||||
container!.innerHTML = panels;
|
container!.innerHTML = panels;
|
||||||
CardSection.bindAll([csAutomations, csScenes]);
|
CardSection.bindAll([csAutomations, csScenes, csPlaylists]);
|
||||||
|
|
||||||
// Event delegation for scene preset card actions
|
// Event delegation for scene preset + playlist card actions
|
||||||
initScenePresetDelegation(container!);
|
initScenePresetDelegation(container!);
|
||||||
|
initPlaylistDelegation(container!);
|
||||||
|
|
||||||
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||||
_automationsTree.update(treeItems, activeTab);
|
_automationsTree.update(treeItems, activeTab);
|
||||||
_automationsTree.observeSections('automations-content', {
|
_automationsTree.observeSections('automations-content', {
|
||||||
'automations': 'automations',
|
'automations': 'automations',
|
||||||
'scenes': 'scenes',
|
'scenes': 'scenes',
|
||||||
|
'playlists': 'playlists',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,11 +349,15 @@ const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
|
|||||||
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
|
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
|
||||||
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
|
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
|
||||||
},
|
},
|
||||||
time_of_day: (c) => ({
|
time_of_day: (c) => {
|
||||||
icon: ICON_CLOCK,
|
const days: number[] = Array.isArray(c.days_of_week) ? c.days_of_week : [];
|
||||||
text: `${c.start_time || '00:00'} – ${c.end_time || '23:59'}`,
|
let text = `${c.start_time || '00:00'} – ${c.end_time || '23:59'}`;
|
||||||
title: t('automations.rule.time_of_day'),
|
if (days.length && days.length < 7) {
|
||||||
}),
|
text += ` · ${[...days].sort((a, b) => a - b).map((d) => t('weekday.short.' + d)).join(' ')}`;
|
||||||
|
}
|
||||||
|
if (c.timezone) text += ` · ${c.timezone}`;
|
||||||
|
return { icon: ICON_CLOCK, text, title: t('automations.rule.time_of_day') };
|
||||||
|
},
|
||||||
system_idle: (c) => {
|
system_idle: (c) => {
|
||||||
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
|
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
|
||||||
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
|
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
|
||||||
@@ -878,6 +891,11 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
|
|||||||
const [sh, sm] = startTime.split(':').map(Number);
|
const [sh, sm] = startTime.split(':').map(Number);
|
||||||
const [eh, em] = endTime.split(':').map(Number);
|
const [eh, em] = endTime.split(':').map(Number);
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const days: number[] = Array.isArray(data.days_of_week) ? data.days_of_week : [];
|
||||||
|
const tz: string = data.timezone || '';
|
||||||
|
const dayChips = [0, 1, 2, 3, 4, 5, 6]
|
||||||
|
.map((d) => `<button type="button" class="weekday-chip${days.includes(d) ? ' active' : ''}" data-day="${d}">${t('weekday.short.' + d)}</button>`)
|
||||||
|
.join('');
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="rule-fields">
|
<div class="rule-fields">
|
||||||
<input type="hidden" class="rule-start-time" value="${startTime}">
|
<input type="hidden" class="rule-start-time" value="${startTime}">
|
||||||
@@ -901,9 +919,21 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rule-weekday-block">
|
||||||
|
<span class="rule-field-label">${t('automations.rule.time_of_day.days')}</span>
|
||||||
|
<div class="weekday-chips">${dayChips}</div>
|
||||||
|
<small class="rule-hint-desc">${t('automations.rule.time_of_day.days_hint')}</small>
|
||||||
|
</div>
|
||||||
|
<div class="rule-tz-block">
|
||||||
|
<label class="rule-field-label">${t('automations.rule.time_of_day.timezone')}</label>
|
||||||
|
<input type="text" class="rule-timezone" placeholder="${t('automations.rule.time_of_day.timezone.placeholder')}" value="${tz}">
|
||||||
|
</div>
|
||||||
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
|
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
|
||||||
</div>`;
|
</div>`;
|
||||||
_wireTimeRangePicker(container);
|
_wireTimeRangePicker(container);
|
||||||
|
container.querySelectorAll('.weekday-chip').forEach((chip) => {
|
||||||
|
chip.addEventListener('click', () => chip.classList.toggle('active'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderSystemIdleFields(container: HTMLElement, data: any): void {
|
function _renderSystemIdleFields(container: HTMLElement, data: any): void {
|
||||||
@@ -1314,6 +1344,9 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
|
|||||||
rule_type: 'time_of_day',
|
rule_type: 'time_of_day',
|
||||||
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
|
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
|
||||||
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
|
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
|
||||||
|
days_of_week: Array.from(row.querySelectorAll('.weekday-chip.active'))
|
||||||
|
.map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)),
|
||||||
|
timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(),
|
||||||
}),
|
}),
|
||||||
system_idle: (row) => ({
|
system_idle: (row) => ({
|
||||||
rule_type: 'system_idle',
|
rule_type: 'system_idle',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW, ICON_DEVICE } from '../c
|
|||||||
import { renderDeviceIcon } from '../core/device-icons.ts';
|
import { renderDeviceIcon } from '../core/device-icons.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import type { Calibration } from '../types.ts';
|
import type { Calibration } from '../types.ts';
|
||||||
|
import { showAutoCalibration } from './auto-calibration.ts';
|
||||||
|
|
||||||
let _calTestDeviceEntitySelect: EntitySelect | null = null;
|
let _calTestDeviceEntitySelect: EntitySelect | null = null;
|
||||||
let _calTestDeviceList: any[] = [];
|
let _calTestDeviceList: any[] = [];
|
||||||
@@ -41,6 +42,10 @@ class CalibrationModal extends Modal {
|
|||||||
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
|
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
|
||||||
skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
|
skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
|
||||||
border_width: (this.$('cal-border-width') as HTMLInputElement).value,
|
border_width: (this.$('cal-border-width') as HTMLInputElement).value,
|
||||||
|
roi_x: (this.$('cal-roi-x') as HTMLInputElement)?.value,
|
||||||
|
roi_y: (this.$('cal-roi-y') as HTMLInputElement)?.value,
|
||||||
|
roi_width: (this.$('cal-roi-width') as HTMLInputElement)?.value,
|
||||||
|
roi_height: (this.$('cal-roi-height') as HTMLInputElement)?.value,
|
||||||
led_count: (this.$('cal-css-led-count') as HTMLInputElement).value,
|
led_count: (this.$('cal-css-led-count') as HTMLInputElement).value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,6 +64,14 @@ class CalibrationModal extends Modal {
|
|||||||
if (testGroup) testGroup.style.display = 'none';
|
if (testGroup) testGroup.style.display = 'none';
|
||||||
const testSection = document.getElementById('calibration-test-setup-section');
|
const testSection = document.getElementById('calibration-test-setup-section');
|
||||||
if (testSection) testSection.style.display = 'none';
|
if (testSection) testSection.style.display = 'none';
|
||||||
|
// Destroy the test-device EntitySelect; otherwise the instance leaks and
|
||||||
|
// leaves a stale enhanced trigger button in the DOM until the next open.
|
||||||
|
if (_calTestDeviceEntitySelect) {
|
||||||
|
_calTestDeviceEntitySelect.destroy();
|
||||||
|
_calTestDeviceEntitySelect = null;
|
||||||
|
}
|
||||||
|
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement | null;
|
||||||
|
if (testDeviceSelect) testDeviceSelect.onchange = null;
|
||||||
} else {
|
} else {
|
||||||
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
|
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
|
||||||
if (deviceId) clearTestMode(deviceId);
|
if (deviceId) clearTestMode(deviceId);
|
||||||
@@ -173,6 +186,7 @@ export async function showCalibration(deviceId: any) {
|
|||||||
updateOffsetSkipLock();
|
updateOffsetSkipLock();
|
||||||
|
|
||||||
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
|
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
|
||||||
|
_populateRoiInputs(calibration);
|
||||||
|
|
||||||
window.edgeSpans = {
|
window.edgeSpans = {
|
||||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||||||
@@ -228,6 +242,33 @@ export async function closeCalibrationModal() {
|
|||||||
calibModal.close();
|
calibModal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the auto-calibration wizard for the currently-open calibration modal.
|
||||||
|
*
|
||||||
|
* Reads the CSS ID or device ID from the active calibration modal context,
|
||||||
|
* then launches the auto-cal modal. In CSS mode the test device (if selected)
|
||||||
|
* is offered as the default device; in device mode the device is known.
|
||||||
|
*/
|
||||||
|
export async function openAutoCalFromCalibration(): Promise<void> {
|
||||||
|
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value || '';
|
||||||
|
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement)?.value || '';
|
||||||
|
|
||||||
|
if (cssId) {
|
||||||
|
// CSS calibration mode: try the already-selected test device as default
|
||||||
|
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement | null;
|
||||||
|
const testDevice = testDeviceSelect?.value || undefined;
|
||||||
|
// Close the calibration modal so the auto-cal modal has focus
|
||||||
|
calibModal.forceClose();
|
||||||
|
await showAutoCalibration(cssId, testDevice);
|
||||||
|
} else if (deviceId) {
|
||||||
|
// Device calibration mode: not directly supported by auto-cal (which
|
||||||
|
// writes to a CSS), so show a toast explaining the constraint.
|
||||||
|
showToast(t('autocal.error.css_required'), 'error');
|
||||||
|
} else {
|
||||||
|
showToast(t('calibration.error.load_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── CSS Calibration support ──────────────────────────────────── */
|
/* ── CSS Calibration support ──────────────────────────────────── */
|
||||||
|
|
||||||
export async function showCSSCalibration(cssId: any) {
|
export async function showCSSCalibration(cssId: any) {
|
||||||
@@ -319,6 +360,7 @@ export async function showCSSCalibration(cssId: any) {
|
|||||||
updateOffsetSkipLock();
|
updateOffsetSkipLock();
|
||||||
|
|
||||||
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
|
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
|
||||||
|
_populateRoiInputs(calibration);
|
||||||
|
|
||||||
window.edgeSpans = {
|
window.edgeSpans = {
|
||||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||||||
@@ -882,6 +924,20 @@ async function clearTestMode(deviceId: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Populate the ROI percentage inputs from a calibration object (fractions 0..1). */
|
||||||
|
function _populateRoiInputs(calibration: any): void {
|
||||||
|
const pct = (v: number | undefined, fallback: number) =>
|
||||||
|
String(Math.round((v ?? fallback) * 100));
|
||||||
|
const set = (id: string, v: string) => {
|
||||||
|
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||||
|
if (el) el.value = v;
|
||||||
|
};
|
||||||
|
set('cal-roi-x', pct(calibration.roi_x, 0));
|
||||||
|
set('cal-roi-y', pct(calibration.roi_y, 0));
|
||||||
|
set('cal-roi-width', pct(calibration.roi_width, 1));
|
||||||
|
set('cal-roi-height', pct(calibration.roi_height, 1));
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveCalibration() {
|
export async function saveCalibration() {
|
||||||
const cssMode = _isCSS();
|
const cssMode = _isCSS();
|
||||||
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
|
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
|
||||||
@@ -936,6 +992,10 @@ export async function saveCalibration() {
|
|||||||
skip_leds_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'),
|
skip_leds_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'),
|
||||||
skip_leds_end: parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'),
|
skip_leds_end: parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'),
|
||||||
border_width: parseInt((document.getElementById('cal-border-width') as HTMLInputElement).value) || 10,
|
border_width: parseInt((document.getElementById('cal-border-width') as HTMLInputElement).value) || 10,
|
||||||
|
roi_x: (parseFloat((document.getElementById('cal-roi-x') as HTMLInputElement).value) || 0) / 100,
|
||||||
|
roi_y: (parseFloat((document.getElementById('cal-roi-y') as HTMLInputElement).value) || 0) / 100,
|
||||||
|
roi_width: (parseFloat((document.getElementById('cal-roi-width') as HTMLInputElement).value) || 100) / 100,
|
||||||
|
roi_height: (parseFloat((document.getElementById('cal-roi-height') as HTMLInputElement).value) || 100) / 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -151,6 +151,15 @@ let _cssTestWs: WebSocket | null = null;
|
|||||||
let _cssTestRaf: number | null = null;
|
let _cssTestRaf: number | null = null;
|
||||||
let _cssTestLatestRgb: Uint8Array | null = null;
|
let _cssTestLatestRgb: Uint8Array | null = null;
|
||||||
let _cssTestMeta: any = null;
|
let _cssTestMeta: any = null;
|
||||||
|
// Set true when a WS frame updates the latest RGB / layer data; the render loop
|
||||||
|
// only repaints when a new frame has arrived, then clears it. Avoids re-rendering
|
||||||
|
// the same frame every RAF tick on low-powered devices (e.g. Android TV WebView).
|
||||||
|
let _cssTestFrameDirty: boolean = false;
|
||||||
|
// Per-canvas render caches: reuse the 2d context and a single ImageData buffer
|
||||||
|
// instead of calling getContext()/createImageData() and resetting width/height
|
||||||
|
// every frame. ImageData is rebuilt only when the canvas LED count changes.
|
||||||
|
const _cssTestCtxCache = new WeakMap<HTMLCanvasElement, CanvasRenderingContext2D>();
|
||||||
|
const _cssTestImageDataCache = new WeakMap<HTMLCanvasElement, { w: number; h: number; imageData: ImageData }>();
|
||||||
let _cssTestSourceId: string | null = null;
|
let _cssTestSourceId: string | null = null;
|
||||||
let _cssTestIsComposite: boolean = false;
|
let _cssTestIsComposite: boolean = false;
|
||||||
let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
|
let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
|
||||||
@@ -234,6 +243,7 @@ function _testKeyColorsSource(sourceId: string) {
|
|||||||
_cssTestLatestRgb = null;
|
_cssTestLatestRgb = null;
|
||||||
_cssTestMeta = null;
|
_cssTestMeta = null;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
_cssTestFrameDirty = false;
|
||||||
|
|
||||||
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
|
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
@@ -414,6 +424,7 @@ function _openTestModal(sourceId: string) {
|
|||||||
_cssTestIsComposite = false;
|
_cssTestIsComposite = false;
|
||||||
_cssTestIsKeyColors = false;
|
_cssTestIsKeyColors = false;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
_cssTestFrameDirty = false;
|
||||||
|
|
||||||
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
|
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
@@ -636,6 +647,8 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
|
|||||||
// Standard format: raw RGB
|
// Standard format: raw RGB
|
||||||
_cssTestLatestRgb = raw;
|
_cssTestLatestRgb = raw;
|
||||||
}
|
}
|
||||||
|
// Mark a new frame so the render loop repaints on the next RAF tick.
|
||||||
|
_cssTestFrameDirty = true;
|
||||||
|
|
||||||
// Track FPS for api_input sources
|
// Track FPS for api_input sources
|
||||||
if (_cssTestIsApiInput) {
|
if (_cssTestIsApiInput) {
|
||||||
@@ -756,6 +769,7 @@ export function applyCssTestSettings() {
|
|||||||
_cssTestLatestRgb = null;
|
_cssTestLatestRgb = null;
|
||||||
_cssTestMeta = null;
|
_cssTestMeta = null;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
_cssTestFrameDirty = false;
|
||||||
|
|
||||||
// Read selected input source from selector (both CSS and CSPT modes)
|
// Read selected input source from selector (both CSS and CSPT modes)
|
||||||
const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null;
|
const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null;
|
||||||
@@ -774,6 +788,9 @@ export function applyCssTestSettings() {
|
|||||||
function _cssTestRenderLoop() {
|
function _cssTestRenderLoop() {
|
||||||
_cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
|
_cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
|
||||||
if (!_cssTestMeta) return;
|
if (!_cssTestMeta) return;
|
||||||
|
// Skip repaint when no new WS frame has arrived since the last render.
|
||||||
|
if (!_cssTestFrameDirty) return;
|
||||||
|
_cssTestFrameDirty = false;
|
||||||
|
|
||||||
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
|
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
|
||||||
|
|
||||||
@@ -786,14 +803,40 @@ function _cssTestRenderLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns a cached 2d context and a reusable ImageData for the canvas, only
|
||||||
|
// resizing the canvas / rebuilding the ImageData when the requested dimensions
|
||||||
|
// differ from the cached ones. Returns null if a 2d context is unavailable.
|
||||||
|
function _cssTestAcquireCanvas(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): { ctx: CanvasRenderingContext2D; imageData: ImageData } | null {
|
||||||
|
let ctx = _cssTestCtxCache.get(canvas);
|
||||||
|
if (!ctx) {
|
||||||
|
const got = canvas.getContext('2d');
|
||||||
|
if (!got) return null;
|
||||||
|
ctx = got;
|
||||||
|
_cssTestCtxCache.set(canvas, ctx);
|
||||||
|
}
|
||||||
|
// Only touch width/height when they actually change (resetting them clears
|
||||||
|
// the backing store and is expensive).
|
||||||
|
if (canvas.width !== w) canvas.width = w;
|
||||||
|
if (canvas.height !== h) canvas.height = h;
|
||||||
|
let cached = _cssTestImageDataCache.get(canvas);
|
||||||
|
if (!cached || cached.w !== w || cached.h !== h) {
|
||||||
|
cached = { w, h, imageData: ctx.createImageData(w, h) };
|
||||||
|
_cssTestImageDataCache.set(canvas, cached);
|
||||||
|
}
|
||||||
|
return { ctx, imageData: cached.imageData };
|
||||||
|
}
|
||||||
|
|
||||||
function _cssTestRenderStrip(rgbBytes: Uint8Array) {
|
function _cssTestRenderStrip(rgbBytes: Uint8Array) {
|
||||||
const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
|
const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ledCount = rgbBytes.length / 3;
|
const ledCount = rgbBytes.length / 3;
|
||||||
canvas.width = ledCount;
|
const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1);
|
||||||
canvas.height = 1;
|
if (!acquired) return;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const { ctx, imageData } = acquired;
|
||||||
const imageData = ctx.createImageData(ledCount, 1);
|
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
for (let i = 0; i < ledCount; i++) {
|
for (let i = 0; i < ledCount; i++) {
|
||||||
const si = i * 3;
|
const si = i * 3;
|
||||||
@@ -824,10 +867,9 @@ function _cssTestRenderLayers(data: any) {
|
|||||||
function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
|
function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
|
||||||
const ledCount = rgbBytes.length / 3;
|
const ledCount = rgbBytes.length / 3;
|
||||||
if (ledCount <= 0) return;
|
if (ledCount <= 0) return;
|
||||||
canvas.width = ledCount;
|
const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1);
|
||||||
canvas.height = 1;
|
if (!acquired) return;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const { ctx, imageData } = acquired;
|
||||||
const imageData = ctx.createImageData(ledCount, 1);
|
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
for (let i = 0; i < ledCount; i++) {
|
for (let i = 0; i < ledCount; i++) {
|
||||||
const si = i * 3;
|
const si = i * 3;
|
||||||
@@ -852,13 +894,17 @@ function _cssTestRenderRect(rgbBytes: Uint8Array, edges: any[]) {
|
|||||||
const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null;
|
const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null;
|
||||||
if (!canvas) continue;
|
if (!canvas) continue;
|
||||||
const count = indices.length;
|
const count = indices.length;
|
||||||
if (count === 0) { canvas.width = 0; continue; }
|
if (count === 0) {
|
||||||
|
if (canvas.width !== 0) canvas.width = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const isH = edge === 'top' || edge === 'bottom';
|
const isH = edge === 'top' || edge === 'bottom';
|
||||||
canvas.width = isH ? count : 1;
|
const w = isH ? count : 1;
|
||||||
canvas.height = isH ? 1 : count;
|
const h = isH ? 1 : count;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const acquired = _cssTestAcquireCanvas(canvas, w, h);
|
||||||
const imageData = ctx.createImageData(canvas.width, canvas.height);
|
if (!acquired) continue;
|
||||||
|
const { ctx, imageData } = acquired;
|
||||||
const px = imageData.data;
|
const px = imageData.data;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const si = indices[i] * 3;
|
const si = indices[i] * 3;
|
||||||
@@ -1185,6 +1231,7 @@ export function closeTestCssSourceModal() {
|
|||||||
_cssTestIsComposite = false;
|
_cssTestIsComposite = false;
|
||||||
_cssTestIsKeyColors = false;
|
_cssTestIsKeyColors = false;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
_cssTestFrameDirty = false;
|
||||||
_cssTestNotificationIds = [];
|
_cssTestNotificationIds = [];
|
||||||
_cssTestIsApiInput = false;
|
_cssTestIsApiInput = false;
|
||||||
_cssTestStopFpsSampling();
|
_cssTestStopFpsSampling();
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [
|
|||||||
'integrations',
|
'integrations',
|
||||||
'automations',
|
'automations',
|
||||||
'scenes',
|
'scenes',
|
||||||
|
'playlists',
|
||||||
'sync-clocks',
|
'sync-clocks',
|
||||||
'targets',
|
'targets',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -69,6 +70,7 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
|
|||||||
integrations: 'dashboard.section.integrations',
|
integrations: 'dashboard.section.integrations',
|
||||||
automations: 'dashboard.section.automations',
|
automations: 'dashboard.section.automations',
|
||||||
scenes: 'dashboard.section.scenes',
|
scenes: 'dashboard.section.scenes',
|
||||||
|
playlists: 'dashboard.section.playlists',
|
||||||
'sync-clocks': 'dashboard.section.sync_clocks',
|
'sync-clocks': 'dashboard.section.sync_clocks',
|
||||||
targets: 'dashboard.section.targets',
|
targets: 'dashboard.section.targets',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type SectionKey =
|
|||||||
| 'integrations'
|
| 'integrations'
|
||||||
| 'automations'
|
| 'automations'
|
||||||
| 'scenes'
|
| 'scenes'
|
||||||
|
| 'playlists'
|
||||||
| 'sync-clocks'
|
| 'sync-clocks'
|
||||||
| 'targets'
|
| 'targets'
|
||||||
// Reserved registry keys for v1.1+ (so saved layouts forward-compat).
|
// Reserved registry keys for v1.1+ (so saved layouts forward-compat).
|
||||||
@@ -151,6 +152,7 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
|
|||||||
_defaultSection('integrations'),
|
_defaultSection('integrations'),
|
||||||
_defaultSection('automations'),
|
_defaultSection('automations'),
|
||||||
_defaultSection('scenes'),
|
_defaultSection('scenes'),
|
||||||
|
_defaultSection('playlists'),
|
||||||
_defaultSection('sync-clocks'),
|
_defaultSection('sync-clocks'),
|
||||||
_defaultSection('targets'),
|
_defaultSection('targets'),
|
||||||
],
|
],
|
||||||
@@ -192,7 +194,7 @@ export const PRESETS: Record<string, () => DashboardLayoutV1> = {
|
|||||||
|
|
||||||
operator: () => {
|
operator: () => {
|
||||||
const l = _clone(DEFAULT_LAYOUT, 'operator');
|
const l = _clone(DEFAULT_LAYOUT, 'operator');
|
||||||
const hide = new Set(['integrations', 'scenes', 'sync-clocks']);
|
const hide = new Set(['integrations', 'scenes', 'playlists', 'sync-clocks']);
|
||||||
l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s);
|
l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s);
|
||||||
l.sections = l.sections.map(s => ({ ...s, density: 'compact' }));
|
l.sections = l.sections.map(s => ({ ...s, density: 'compact' }));
|
||||||
return l;
|
return l;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS,
|
ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS,
|
||||||
} from '../core/icons.ts';
|
} from '../core/icons.ts';
|
||||||
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
|
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
|
||||||
|
import { loadPlaylists } from './scene-playlists.ts';
|
||||||
import { cardColorStyle } from '../core/card-colors.ts';
|
import { cardColorStyle } from '../core/card-colors.ts';
|
||||||
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
||||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||||
@@ -55,7 +56,7 @@ function _mountDashboardCardModeToggles(): void {
|
|||||||
_dashboardModeTeardowns.set(surface, teardown);
|
_dashboardModeTeardowns.set(surface, teardown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
|
import type { Device, OutputTarget, ColorStripSource, ScenePreset, ScenePlaylist, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
|
||||||
|
|
||||||
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
||||||
const MAX_FPS_SAMPLES = 120;
|
const MAX_FPS_SAMPLES = 120;
|
||||||
@@ -529,6 +530,49 @@ function renderDashboardSyncClock(clock: SyncClock): string {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact dashboard card for a scene playlist. Mirrors the sync-clock card:
|
||||||
|
* running state drives the LED / patch indicator and the Start↔Stop toggle.
|
||||||
|
* Only one playlist cycles at a time, so the running one is sorted to the
|
||||||
|
* front by the caller. Uses the window-exposed start/stop handlers (same as
|
||||||
|
* the Automations-tab cards), so no extra delegation wiring is needed. */
|
||||||
|
function renderDashboardPlaylist(playlist: ScenePlaylist): string {
|
||||||
|
const running = playlist.is_running === true;
|
||||||
|
const itemCount = (playlist.items || []).length;
|
||||||
|
const metaParts = [
|
||||||
|
itemCount > 0 ? `${itemCount} ${t('playlists.scenes_count')}` : null,
|
||||||
|
playlist.description ? escapeHtml(playlist.description) : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
const short = (playlist.id || '').replace(/^playlist_/i, '').slice(-2).toUpperCase() || 'PL';
|
||||||
|
const ledCls = running ? 'led on blink' : 'led';
|
||||||
|
const patchLabel = running ? t('playlists.status.playing') : t('playlists.status.stopped');
|
||||||
|
const patchLive = running ? ' is-live' : '';
|
||||||
|
const btnCls = running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
|
||||||
|
const btnLabel = running ? (t('playlists.action.stop') || 'Stop') : (t('playlists.action.start') || 'Start');
|
||||||
|
const btnTitle = running ? t('playlists.stop') : t('playlists.start');
|
||||||
|
const toggleAction = running ? 'stopScenePlaylist()' : `startScenePlaylist('${playlist.id}')`;
|
||||||
|
|
||||||
|
const plStyle = cardColorStyle(playlist.id);
|
||||||
|
const iconPlate = _dashboardIconPlate(playlist as any);
|
||||||
|
const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
|
||||||
|
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${running ? 'is-running' : ''}" data-playlist-id="${playlist.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations','playlists','playlists','data-playlist-id','${playlist.id}')}"${plStyle ? ` style="${plStyle}"` : ''}>
|
||||||
|
<div class="${headCls}">
|
||||||
|
${iconPlate}
|
||||||
|
<div class="mod-id">
|
||||||
|
<span class="mod-badge">PL · ${escapeHtml(short)}</span>
|
||||||
|
<div class="mod-name"><span>${escapeHtml(playlist.name)}</span></div>
|
||||||
|
${metaParts.length ? `<div class="mod-meta">${metaParts.join(' · ')}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="mod-leds" aria-hidden="true">
|
||||||
|
<span class="${ledCls}"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mod-foot">
|
||||||
|
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
|
||||||
|
<button class="${btnCls}" onclick="event.stopPropagation(); ${toggleAction}" title="${btnTitle}">${running ? ICON_PAUSE : ICON_START} <span>${btnLabel}</span></button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
|
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||||
/** Called from the transport-bar poll cycler (and any legacy callers
|
/** Called from the transport-bar poll cycler (and any legacy callers
|
||||||
* that might still reference `window.changeDashboardPollInterval`). */
|
* that might still reference `window.changeDashboardPollInterval`). */
|
||||||
@@ -644,7 +688,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Fire all requests in a single batch to avoid sequential RTTs
|
// Fire all requests in a single batch to avoid sequential RTTs
|
||||||
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
|
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, playlists, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
|
||||||
outputTargetsCache.fetch().catch((): any[] => []),
|
outputTargetsCache.fetch().catch((): any[] => []),
|
||||||
fetchWithAuth('/automations').catch(() => null),
|
fetchWithAuth('/automations').catch(() => null),
|
||||||
devicesCache.fetch().catch((): any[] => []),
|
devicesCache.fetch().catch((): any[] => []),
|
||||||
@@ -652,6 +696,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
||||||
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
||||||
loadScenePresets(),
|
loadScenePresets(),
|
||||||
|
loadPlaylists().catch((): ScenePlaylist[] => []),
|
||||||
fetchWithAuth('/sync-clocks').catch(() => null),
|
fetchWithAuth('/sync-clocks').catch(() => null),
|
||||||
fetchWithAuth('/home-assistant/status').catch(() => null),
|
fetchWithAuth('/home-assistant/status').catch(() => null),
|
||||||
fetchWithAuth('/mqtt/status').catch(() => null),
|
fetchWithAuth('/mqtt/status').catch(() => null),
|
||||||
@@ -717,7 +762,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
// Build dynamic HTML (targets, automations)
|
// Build dynamic HTML (targets, automations)
|
||||||
let dynamicHtml = '';
|
let dynamicHtml = '';
|
||||||
let runningIds: any[] = [];
|
let runningIds: any[] = [];
|
||||||
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
|
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && playlists.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
|
||||||
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
const enriched = targets.map(target => ({
|
const enriched = targets.map(target => ({
|
||||||
@@ -906,6 +951,19 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scene Playlists section — running playlist (if any) sorts first.
|
||||||
|
if (playlists.length > 0) {
|
||||||
|
const ordered = [...playlists].sort(
|
||||||
|
(a, b) => Number(b.is_running === true) - Number(a.is_running === true),
|
||||||
|
);
|
||||||
|
const playlistCards = ordered.map(p => renderDashboardPlaylist(p)).join('');
|
||||||
|
const playlistGrid = `<div class="dashboard-autostart-grid">${playlistCards}</div>`;
|
||||||
|
sectionFragments['playlists'] = `<div class="dashboard-section" data-section="playlists">
|
||||||
|
${_sectionHeader('playlists', t('dashboard.section.playlists'), playlists.length, '', 'dashboard-playlists')}
|
||||||
|
${_sectionContent('playlists', playlistGrid)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Sync Clocks section
|
// Sync Clocks section
|
||||||
if (syncClocks.length > 0) {
|
if (syncClocks.length > 0) {
|
||||||
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
|
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
/**
|
||||||
|
* Scene Playlists — ordered, timed sequences of scene presets that auto-cycle.
|
||||||
|
* Rendered as a CardSection inside the Automations tab (third sub-tab).
|
||||||
|
*
|
||||||
|
* A playlist activates each referenced scene preset in turn and holds it for
|
||||||
|
* that item's dwell duration, then advances. Only one playlist cycles at a
|
||||||
|
* time; starting one stops any other.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeHtml } from '../core/api.ts';
|
||||||
|
import { apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||||
|
import { t } from '../core/i18n.ts';
|
||||||
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
|
import { Modal } from '../core/modal.ts';
|
||||||
|
import { CardSection } from '../core/card-sections.ts';
|
||||||
|
import {
|
||||||
|
ICON_START, ICON_PAUSE, ICON_EDIT, ICON_TRASH, ICON_LINK, ICON_REFRESH, ICON_CLOCK,
|
||||||
|
} from '../core/icons.ts';
|
||||||
|
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
||||||
|
import { scenePlaylistsCache, scenePresetsCache } from '../core/state.ts';
|
||||||
|
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||||
|
import { wrapCard } from '../core/card-colors.ts';
|
||||||
|
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
|
||||||
|
import { EntityPalette } from '../core/entity-palette.ts';
|
||||||
|
import { navigateToCard } from '../core/navigation.ts';
|
||||||
|
import { isActiveTab } from '../core/tab-registry.ts';
|
||||||
|
import { makeCardIconFields } from '../core/card-icon.ts';
|
||||||
|
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
|
||||||
|
import type { ScenePlaylist, ScenePreset } from '../types.ts';
|
||||||
|
|
||||||
|
const DEFAULT_ITEM_DURATION = 30;
|
||||||
|
const MIN_ITEM_DURATION = 1;
|
||||||
|
const SCENE_DOT = '<span class="scene-color-dot" style="background:#4fc3f7"></span>';
|
||||||
|
|
||||||
|
registerIconEntityType('scene_playlist', makeSimpleIconAdapter<ScenePlaylist>({
|
||||||
|
cache: scenePlaylistsCache,
|
||||||
|
endpointPrefix: '/scene-playlists',
|
||||||
|
reload: async () => {
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
if (typeof window.loadAutomations === 'function') {
|
||||||
|
await window.loadAutomations();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
typeLabelKey: 'device.icon.entity.scene_playlist',
|
||||||
|
typeLabelFallback: 'Playlist',
|
||||||
|
cardSelectors: (id) => [`[data-playlist-id="${CSS.escape(id)}"]`],
|
||||||
|
}));
|
||||||
|
|
||||||
|
let _editingId: string | null = null;
|
||||||
|
let _playlistTagsInput: TagInput | null = null;
|
||||||
|
let _presetMap: Record<string, ScenePreset> = {};
|
||||||
|
|
||||||
|
// ── Scene-preset lookup helpers ──
|
||||||
|
|
||||||
|
async function _primePresets(): Promise<void> {
|
||||||
|
const presets = await scenePresetsCache.fetch().catch((): ScenePreset[] => []);
|
||||||
|
_presetMap = {};
|
||||||
|
for (const p of presets) _presetMap[p.id] = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _presetName(presetId: string): string {
|
||||||
|
return _presetMap[presetId]?.name || presetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _presetIconSvg(preset: ScenePreset | undefined): string {
|
||||||
|
const svg = preset?.icon ? renderDeviceIconSvg(preset.icon, { size: 18 }) : '';
|
||||||
|
return svg || SCENE_DOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Item row rendering (editor) ──
|
||||||
|
|
||||||
|
function _renderItemRowHtml(presetId: string, duration: number): string {
|
||||||
|
const preset = _presetMap[presetId];
|
||||||
|
const removeLabel = t('common.remove') || 'Remove';
|
||||||
|
const upLabel = t('playlists.item.move_up') || 'Move up';
|
||||||
|
const downLabel = t('playlists.item.move_down') || 'Move down';
|
||||||
|
const missing = preset ? '' : ' playlist-item--missing';
|
||||||
|
return `
|
||||||
|
<div class="playlist-item-icon" aria-hidden="true">${_presetIconSvg(preset)}</div>
|
||||||
|
<div class="playlist-item-id">
|
||||||
|
<span class="playlist-item-name">${escapeHtml(_presetName(presetId))}</span>
|
||||||
|
<span class="playlist-item-type${missing}">${preset ? 'SCN' : (t('playlists.item.missing') || 'MISSING')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-item-duration-wrap">
|
||||||
|
${ICON_CLOCK}
|
||||||
|
<input type="number" class="playlist-item-duration" min="${MIN_ITEM_DURATION}" step="1"
|
||||||
|
value="${Math.max(MIN_ITEM_DURATION, Math.round(duration))}"
|
||||||
|
aria-label="${escapeHtml(t('playlists.item.duration') || 'Seconds')}">
|
||||||
|
<span class="playlist-item-unit">s</span>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-item-actions">
|
||||||
|
<button type="button" class="playlist-item-btn" data-action="playlist-item-up" title="${escapeHtml(upLabel)}" aria-label="${escapeHtml(upLabel)}">↑</button>
|
||||||
|
<button type="button" class="playlist-item-btn" data-action="playlist-item-down" title="${escapeHtml(downLabel)}" aria-label="${escapeHtml(downLabel)}">↓</button>
|
||||||
|
<button type="button" class="playlist-item-btn playlist-item-remove" data-action="playlist-item-remove" title="${escapeHtml(removeLabel)}" aria-label="${escapeHtml(removeLabel)}">${ICON_TRASH}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _appendItemRow(presetId: string, duration: number, listEl: HTMLElement): void {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'playlist-item';
|
||||||
|
item.dataset.presetId = presetId;
|
||||||
|
item.dataset.duration = String(duration);
|
||||||
|
item.innerHTML = _renderItemRowHtml(presetId, duration);
|
||||||
|
listEl.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _readEditorItems(): Array<{ scene_preset_id: string; duration_seconds: number }> {
|
||||||
|
return [...document.querySelectorAll('#playlist-item-list .playlist-item')].map(el => {
|
||||||
|
const row = el as HTMLElement;
|
||||||
|
const input = row.querySelector('.playlist-item-duration') as HTMLInputElement | null;
|
||||||
|
const raw = input ? parseFloat(input.value) : DEFAULT_ITEM_DURATION;
|
||||||
|
const duration = Number.isFinite(raw) && raw >= MIN_ITEM_DURATION ? raw : MIN_ITEM_DURATION;
|
||||||
|
return { scene_preset_id: row.dataset.presetId || '', duration_seconds: duration };
|
||||||
|
}).filter(i => i.scene_preset_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setItemListEmptyHint(listEl: HTMLElement): void {
|
||||||
|
listEl.dataset.empty = t('playlists.items.empty') || 'No scenes yet — add some below';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-name ──
|
||||||
|
|
||||||
|
let _plNameManuallyEdited = false;
|
||||||
|
|
||||||
|
function _autoGeneratePlaylistName(): void {
|
||||||
|
if (_plNameManuallyEdited) return;
|
||||||
|
if ((document.getElementById('playlist-editor-id') as HTMLInputElement).value) return;
|
||||||
|
const count = document.querySelectorAll('#playlist-item-list .playlist-item').length;
|
||||||
|
const label = count > 0
|
||||||
|
? `${t('playlists.title')} · ${count} ${count === 1 ? (t('playlists.scene_one') || 'scene') : (t('playlists.scene_many') || 'scenes')}`
|
||||||
|
: t('playlists.title');
|
||||||
|
(document.getElementById('playlist-editor-name') as HTMLInputElement).value = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlaylistEditorModal extends Modal {
|
||||||
|
constructor() { super('playlist-editor-modal'); }
|
||||||
|
onForceClose() {
|
||||||
|
if (_playlistTagsInput) { _playlistTagsInput.destroy(); _playlistTagsInput = null; }
|
||||||
|
}
|
||||||
|
snapshotValues() {
|
||||||
|
const items = _readEditorItems().map(i => `${i.scene_preset_id}:${i.duration_seconds}`).join(',');
|
||||||
|
return {
|
||||||
|
name: (document.getElementById('playlist-editor-name') as HTMLInputElement).value,
|
||||||
|
description: (document.getElementById('playlist-editor-description') as HTMLInputElement).value,
|
||||||
|
loop: (document.getElementById('playlist-editor-loop') as HTMLInputElement).checked.toString(),
|
||||||
|
shuffle: (document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked.toString(),
|
||||||
|
items,
|
||||||
|
tags: JSON.stringify(_playlistTagsInput ? _playlistTagsInput.getValue() : []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const playlistModal = new PlaylistEditorModal();
|
||||||
|
|
||||||
|
export const csPlaylists = new CardSection('playlists', {
|
||||||
|
titleKey: 'playlists.title',
|
||||||
|
gridClass: 'devices-grid',
|
||||||
|
addCardOnclick: 'openPlaylistEditor()',
|
||||||
|
keyAttr: 'data-playlist-id',
|
||||||
|
emptyKey: 'section.empty.playlists',
|
||||||
|
bulkActions: [{
|
||||||
|
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete',
|
||||||
|
handler: async (ids) => {
|
||||||
|
const results = await Promise.allSettled(ids.map(id => apiDelete(`/scene-playlists/${id}`)));
|
||||||
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
|
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||||
|
else showToast(t('playlists.deleted'), 'success');
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
if (window.loadAutomations) window.loadAutomations();
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createPlaylistCard(playlist: ScenePlaylist): string {
|
||||||
|
const itemCount = (playlist.items || []).length;
|
||||||
|
const running = playlist.is_running === true;
|
||||||
|
const updated = playlist.updated_at ? new Date(playlist.updated_at).toLocaleString() : '';
|
||||||
|
const shortId = (playlist.id || '').replace(/^playlist_/i, '').slice(-2).toUpperCase() || 'NA';
|
||||||
|
|
||||||
|
const metaParts: string[] = [];
|
||||||
|
if (itemCount > 0) metaParts.push(`${itemCount} ${t('playlists.scenes_count')}`);
|
||||||
|
if (updated) metaParts.push(updated);
|
||||||
|
const metaHtml = metaParts.length ? metaParts.map(escapeHtml).join(' · ') : undefined;
|
||||||
|
|
||||||
|
const chips: ModChipOpts[] = [];
|
||||||
|
if (playlist.loop) chips.push({ icon: ICON_REFRESH, text: t('playlists.chip.loop'), variant: 'tag' });
|
||||||
|
if (playlist.shuffle) chips.push({ icon: ICON_LINK, text: t('playlists.chip.shuffle'), variant: 'tag' });
|
||||||
|
|
||||||
|
const leds: LedState[] = [running ? 'on' : 'off'];
|
||||||
|
|
||||||
|
const primaryAction = running
|
||||||
|
? {
|
||||||
|
label: t('playlists.action.stop'),
|
||||||
|
icon: ICON_PAUSE,
|
||||||
|
onclick: `stopScenePlaylist()`,
|
||||||
|
title: t('playlists.stop'),
|
||||||
|
variant: 'stop' as const,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: t('playlists.action.start'),
|
||||||
|
icon: ICON_START,
|
||||||
|
onclick: `startScenePlaylist('${playlist.id}')`,
|
||||||
|
title: t('playlists.start'),
|
||||||
|
variant: 'go' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mod: ModCardOpts = {
|
||||||
|
head: {
|
||||||
|
badge: { text: `PL · ${shortId}` },
|
||||||
|
name: playlist.name,
|
||||||
|
metaHtml,
|
||||||
|
leds,
|
||||||
|
...makeCardIconFields('scene_playlist', playlist.id, playlist),
|
||||||
|
menu: {
|
||||||
|
duplicateOnclick: `clonePlaylist('${playlist.id}')`,
|
||||||
|
hideOnclick: `toggleCardHidden('playlists','${playlist.id}')`,
|
||||||
|
deleteOnclick: `deletePlaylist('${playlist.id}')`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
desc: playlist.description || undefined,
|
||||||
|
chips: chips.length ? chips : undefined,
|
||||||
|
},
|
||||||
|
foot: {
|
||||||
|
patchState: running ? 'live' : 'idle',
|
||||||
|
patchLabel: running ? t('playlists.status.playing') : t('playlists.status.stopped'),
|
||||||
|
primaryAction,
|
||||||
|
iconActions: [{
|
||||||
|
icon: ICON_EDIT,
|
||||||
|
onclick: `editPlaylist('${playlist.id}')`,
|
||||||
|
title: t('playlists.edit'),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardHtml = wrapCard({ dataAttr: 'data-playlist-id', id: playlist.id, mod });
|
||||||
|
const tagsHtml = renderTagChips(playlist.tags);
|
||||||
|
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPlaylists(): Promise<ScenePlaylist[]> {
|
||||||
|
return scenePlaylistsCache.fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Create / Edit / Clone =====
|
||||||
|
|
||||||
|
function _resetEditorChrome(titleKey: string): void {
|
||||||
|
(document.getElementById('playlist-editor-error') as HTMLElement).style.display = 'none';
|
||||||
|
const titleEl = document.querySelector('#playlist-editor-title span[data-i18n]');
|
||||||
|
if (titleEl) { titleEl.setAttribute('data-i18n', titleKey); titleEl.textContent = t(titleKey); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wireAutoName(): void {
|
||||||
|
_plNameManuallyEdited = false;
|
||||||
|
(document.getElementById('playlist-editor-name') as HTMLElement).oninput = () => { _plNameManuallyEdited = true; };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _initTags(values: string[]): void {
|
||||||
|
if (_playlistTagsInput) { _playlistTagsInput.destroy(); _playlistTagsInput = null; }
|
||||||
|
_playlistTagsInput = new TagInput(document.getElementById('playlist-tags-container'), { placeholder: t('tags.placeholder') });
|
||||||
|
_playlistTagsInput.setValue(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _openEditorWith(opts: {
|
||||||
|
editingId: string | null;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
loop: boolean;
|
||||||
|
shuffle: boolean;
|
||||||
|
items: Array<{ scene_preset_id: string; duration_seconds: number }>;
|
||||||
|
tags: string[];
|
||||||
|
titleKey: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
_editingId = opts.editingId;
|
||||||
|
(document.getElementById('playlist-editor-id') as HTMLInputElement).value = opts.editingId || '';
|
||||||
|
(document.getElementById('playlist-editor-name') as HTMLInputElement).value = opts.name;
|
||||||
|
(document.getElementById('playlist-editor-description') as HTMLInputElement).value = opts.description;
|
||||||
|
(document.getElementById('playlist-editor-loop') as HTMLInputElement).checked = opts.loop;
|
||||||
|
(document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked = opts.shuffle;
|
||||||
|
_resetEditorChrome(opts.titleKey);
|
||||||
|
|
||||||
|
const list = document.getElementById('playlist-item-list');
|
||||||
|
if (list) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
_setItemListEmptyHint(list);
|
||||||
|
await _primePresets();
|
||||||
|
for (const it of opts.items) _appendItemRow(it.scene_preset_id, it.duration_seconds, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
_initTags(opts.tags);
|
||||||
|
_wireAutoName();
|
||||||
|
if (!opts.editingId) _autoGeneratePlaylistName();
|
||||||
|
|
||||||
|
playlistModal.open();
|
||||||
|
playlistModal.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPlaylistEditor(): Promise<void> {
|
||||||
|
await _openEditorWith({
|
||||||
|
editingId: null, name: '', description: '', loop: true, shuffle: false,
|
||||||
|
items: [], tags: [], titleKey: 'playlists.add',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editPlaylist(playlistId: string): Promise<void> {
|
||||||
|
const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId);
|
||||||
|
if (!playlist) return;
|
||||||
|
await _openEditorWith({
|
||||||
|
editingId: playlistId,
|
||||||
|
name: playlist.name,
|
||||||
|
description: playlist.description || '',
|
||||||
|
loop: playlist.loop !== false,
|
||||||
|
shuffle: playlist.shuffle === true,
|
||||||
|
items: (playlist.items || []).map(i => ({ scene_preset_id: i.scene_preset_id, duration_seconds: i.duration_seconds })),
|
||||||
|
tags: playlist.tags || [],
|
||||||
|
titleKey: 'playlists.edit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clonePlaylist(playlistId: string): Promise<void> {
|
||||||
|
const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId);
|
||||||
|
if (!playlist) return;
|
||||||
|
await _openEditorWith({
|
||||||
|
editingId: null,
|
||||||
|
name: `${playlist.name || ''} (Copy)`,
|
||||||
|
description: playlist.description || '',
|
||||||
|
loop: playlist.loop !== false,
|
||||||
|
shuffle: playlist.shuffle === true,
|
||||||
|
items: (playlist.items || []).map(i => ({ scene_preset_id: i.scene_preset_id, duration_seconds: i.duration_seconds })),
|
||||||
|
tags: playlist.tags || [],
|
||||||
|
titleKey: 'playlists.add',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePlaylist(): Promise<void> {
|
||||||
|
if (playlistModal.closeIfPristine(_editingId)) return;
|
||||||
|
|
||||||
|
const name = (document.getElementById('playlist-editor-name') as HTMLInputElement).value.trim();
|
||||||
|
const description = (document.getElementById('playlist-editor-description') as HTMLInputElement).value.trim();
|
||||||
|
const loop = (document.getElementById('playlist-editor-loop') as HTMLInputElement).checked;
|
||||||
|
const shuffle = (document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked;
|
||||||
|
const errorEl = document.getElementById('playlist-editor-error')!;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
errorEl.textContent = t('playlists.error.name_required');
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = _readEditorItems();
|
||||||
|
const tags = _playlistTagsInput ? _playlistTagsInput.getValue() : [];
|
||||||
|
const body = { name, description, loop, shuffle, items, tags };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_editingId) {
|
||||||
|
await apiPut(`/scene-playlists/${_editingId}`, body, { errorMessage: t('playlists.error.save_failed') });
|
||||||
|
} else {
|
||||||
|
await apiPost('/scene-playlists', body, { errorMessage: t('playlists.error.save_failed') });
|
||||||
|
}
|
||||||
|
playlistModal.forceClose();
|
||||||
|
showToast(_editingId ? t('playlists.updated') : t('playlists.created'), 'success');
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
_reloadPlaylistsTab();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
errorEl.textContent = error.message || t('playlists.error.save_failed');
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePlaylistEditor(): Promise<void> {
|
||||||
|
await playlistModal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Item selector =====
|
||||||
|
|
||||||
|
export async function addPlaylistItem(): Promise<void> {
|
||||||
|
await _primePresets();
|
||||||
|
const presets = Object.values(_presetMap);
|
||||||
|
if (presets.length === 0) {
|
||||||
|
showToast(t('playlists.error.no_presets') || 'Create a scene preset first', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = presets.map(p => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.name,
|
||||||
|
icon: _presetIconSvg(p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const picked = await EntityPalette.pick({
|
||||||
|
items,
|
||||||
|
placeholder: t('playlists.items.search_placeholder'),
|
||||||
|
});
|
||||||
|
if (!picked) return;
|
||||||
|
|
||||||
|
const list = document.getElementById('playlist-item-list');
|
||||||
|
if (list) {
|
||||||
|
_appendItemRow(String(picked), DEFAULT_ITEM_DURATION, list);
|
||||||
|
_autoGeneratePlaylistName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Start / Stop =====
|
||||||
|
|
||||||
|
export async function startScenePlaylist(playlistId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiPost(`/scene-playlists/${playlistId}/start`, undefined, { errorMessage: t('playlists.error.start_failed') });
|
||||||
|
showToast(t('playlists.started'), 'success');
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
_reloadPlaylistsTab();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
showToast(error.message || t('playlists.error.start_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopScenePlaylist(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiPost('/scene-playlists/stop', undefined, { errorMessage: t('playlists.error.stop_failed') });
|
||||||
|
showToast(t('playlists.stopped'), 'success');
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
_reloadPlaylistsTab();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
showToast(error.message || t('playlists.error.stop_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Delete =====
|
||||||
|
|
||||||
|
export async function deletePlaylist(playlistId: string): Promise<void> {
|
||||||
|
const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId);
|
||||||
|
const name = playlist ? playlist.name : playlistId;
|
||||||
|
const confirmed = await showConfirm(t('playlists.delete_confirm', { name }));
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiDelete(`/scene-playlists/${playlistId}`, { errorMessage: t('playlists.error.delete_failed') });
|
||||||
|
showToast(t('playlists.deleted'), 'success');
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
_reloadPlaylistsTab();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
showToast(error.message || t('playlists.error.delete_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Event delegation =====
|
||||||
|
|
||||||
|
const _playlistCardActions: Record<string, (id: string) => void> = {
|
||||||
|
'delete-playlist': deletePlaylist,
|
||||||
|
'clone-playlist': clonePlaylist,
|
||||||
|
'edit-playlist': editPlaylist,
|
||||||
|
'start-playlist': startScenePlaylist,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function initPlaylistDelegation(container: HTMLElement): void {
|
||||||
|
container.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
if (action === 'add-playlist') {
|
||||||
|
e.stopPropagation();
|
||||||
|
openPlaylistEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'stop-playlist') {
|
||||||
|
e.stopPropagation();
|
||||||
|
stopScenePlaylist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'navigate-playlist') {
|
||||||
|
if ((e.target as HTMLElement).closest('button')) return;
|
||||||
|
navigateToCard('automations', 'playlists', 'playlists', 'data-playlist-id', id!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) return;
|
||||||
|
const handler = _playlistCardActions[action];
|
||||||
|
if (handler) {
|
||||||
|
e.stopPropagation();
|
||||||
|
handler(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
|
||||||
|
function _reloadPlaylistsTab(): void {
|
||||||
|
if (isActiveTab('automations') && typeof window.loadAutomations === 'function') {
|
||||||
|
window.loadAutomations();
|
||||||
|
}
|
||||||
|
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Editor modal item-list delegation (reorder / remove / duration) =====
|
||||||
|
|
||||||
|
const _playlistEditorModal = document.getElementById('playlist-editor-modal');
|
||||||
|
if (_playlistEditorModal) {
|
||||||
|
_playlistEditorModal.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const row = btn.closest('.playlist-item') as HTMLElement | null;
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
if (action === 'playlist-item-remove') {
|
||||||
|
row.remove();
|
||||||
|
_autoGeneratePlaylistName();
|
||||||
|
} else if (action === 'playlist-item-up') {
|
||||||
|
const prev = row.previousElementSibling;
|
||||||
|
if (prev) row.parentElement!.insertBefore(row, prev);
|
||||||
|
} else if (action === 'playlist-item-down') {
|
||||||
|
const next = row.nextElementSibling;
|
||||||
|
if (next) row.parentElement!.insertBefore(next, row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-refresh playlist cards when the engine reports a state change
|
||||||
|
// (start / advance / natural-completion stop) so the running indicator and
|
||||||
|
// Start/Stop button stay accurate across clients and after a playlist ends.
|
||||||
|
document.addEventListener('server:playlist_state_changed', () => {
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
_reloadPlaylistsTab();
|
||||||
|
});
|
||||||
@@ -0,0 +1,846 @@
|
|||||||
|
/**
|
||||||
|
* Setup Wizard — multi-step first-run flow.
|
||||||
|
*
|
||||||
|
* Guides a brand-new user from zero to a running, calibrated LED strip in
|
||||||
|
* roughly seven steps:
|
||||||
|
* 1. Welcome
|
||||||
|
* 2. Find device — discovery scan + manual add fallback
|
||||||
|
* 3. Pick screen — GET /api/v1/config/displays
|
||||||
|
* 4. Scaffold — POST /api/v1/setup/scaffold → entity ids
|
||||||
|
* 5. Calibrate — embed mountAutoCalibration (Phase 3 component)
|
||||||
|
* 6. Start output — POST /api/v1/output-targets/{id}/start
|
||||||
|
* 7. Done
|
||||||
|
*
|
||||||
|
* First-run precedence (explicit):
|
||||||
|
* - app.ts checks GET /preferences/onboarding
|
||||||
|
* - if onboarded=false AND no output targets → open wizard, suppress tour
|
||||||
|
* - wizard completion/skip → PUT /preferences/onboarding {onboarded:true}
|
||||||
|
* + localStorage 'tour_completed' = '1' so the tour never double-fires
|
||||||
|
* - if onboarded=true → existing tour logic runs unchanged
|
||||||
|
*
|
||||||
|
* Re-entrant: openSetupWizard() is exported so a toolbar button can reopen it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
|
||||||
|
import { devicesCache, outputTargetsCache, displaysCache } from '../core/state.ts';
|
||||||
|
import { t } from '../core/i18n.ts';
|
||||||
|
import { showToast } from '../core/ui.ts';
|
||||||
|
import { Modal } from '../core/modal.ts';
|
||||||
|
import { mountAutoCalibration, unmountAutoCalibration } from './auto-calibration.ts';
|
||||||
|
import { suppressGettingStartedTour } from './tutorials.ts';
|
||||||
|
import {
|
||||||
|
ICON_MONITOR, ICON_SPARKLES, ICON_DEVICE, ICON_OK, ICON_CHECK, ICON_ROCKET_ICON,
|
||||||
|
ICON_CALIBRATION, ICON_START, ICON_SEARCH, ICON_PLUS,
|
||||||
|
} from '../core/icons.ts';
|
||||||
|
import { getDeviceTypeIcon } from '../core/icons.ts';
|
||||||
|
import type { Device } from '../types.ts';
|
||||||
|
import type { Display } from '../types.ts';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type WizardStep = 'welcome' | 'device' | 'display' | 'scaffold' | 'calibrate' | 'start' | 'done';
|
||||||
|
|
||||||
|
interface DiscoveredDevice {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
device_type: string;
|
||||||
|
led_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScaffoldResult {
|
||||||
|
device_id: string;
|
||||||
|
capture_template_id: string;
|
||||||
|
picture_source_id: string;
|
||||||
|
color_strip_source_id: string;
|
||||||
|
output_target_id: string;
|
||||||
|
capture_template_reused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WizardState {
|
||||||
|
step: WizardStep;
|
||||||
|
/** Persisted device id after creation. */
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
displayIndex: number;
|
||||||
|
displayName: string;
|
||||||
|
scaffoldResult: ScaffoldResult | null;
|
||||||
|
/** Populated by step 2 discovery scan. */
|
||||||
|
discoveredDevices: DiscoveredDevice[];
|
||||||
|
/** Manual-entry mode in step 2. */
|
||||||
|
manualMode: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
errorMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Module singleton ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _state: WizardState | null = null;
|
||||||
|
let _modal: SetupWizardModal | null = null;
|
||||||
|
/** First render after open: let the Modal's own autofocus win; only steal focus
|
||||||
|
* on subsequent step transitions (for D-pad / TV navigation). */
|
||||||
|
let _firstRender = true;
|
||||||
|
|
||||||
|
const STEPS: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start', 'done'];
|
||||||
|
|
||||||
|
// ── Modal class ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SetupWizardModal extends Modal {
|
||||||
|
constructor() {
|
||||||
|
super('setup-wizard-modal');
|
||||||
|
}
|
||||||
|
onForceClose(): void {
|
||||||
|
_handleWizardClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Open the wizard (first-run or on-demand). */
|
||||||
|
export function openSetupWizard(): void {
|
||||||
|
if (!_modal) _modal = new SetupWizardModal();
|
||||||
|
_firstRender = true;
|
||||||
|
_state = {
|
||||||
|
step: 'welcome',
|
||||||
|
deviceId: '',
|
||||||
|
deviceName: '',
|
||||||
|
displayIndex: 0,
|
||||||
|
displayName: '',
|
||||||
|
scaffoldResult: null,
|
||||||
|
discoveredDevices: [],
|
||||||
|
manualMode: false,
|
||||||
|
busy: false,
|
||||||
|
errorMsg: '',
|
||||||
|
};
|
||||||
|
_modal.open();
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the wizard and mark as complete / skipped. */
|
||||||
|
export function closeSetupWizard(): void {
|
||||||
|
if (!_modal) return;
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
_modal.forceClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// First-run check (called from app.ts after auth passes)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check onboarding state and open the wizard on true first run.
|
||||||
|
*
|
||||||
|
* Returns `true` if the wizard was opened (caller should suppress the tour).
|
||||||
|
* Returns `false` if already onboarded (caller should proceed with tour logic).
|
||||||
|
*/
|
||||||
|
export async function checkAndOpenWizardIfNeeded(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [onboardingResp, targetsResp] = await Promise.all([
|
||||||
|
apiGet<{ onboarded: boolean; completed_at: string | null }>('/preferences/onboarding'),
|
||||||
|
outputTargetsCache.fetch().catch((): unknown[] => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (onboardingResp.onboarded) {
|
||||||
|
// Already onboarded — let tour run normally
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = Array.isArray(targetsResp) ? targetsResp : [];
|
||||||
|
if (targets.length > 0) {
|
||||||
|
// Has output targets but never completed onboarding wizard.
|
||||||
|
// Power user or migrated setup — mark done and skip wizard.
|
||||||
|
await _markOnboarded();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True first run: no targets, not onboarded
|
||||||
|
openSetupWizard();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// If the check itself fails (server offline, 404 on new backend, etc.)
|
||||||
|
// fall through to existing tour logic — don't block the UI.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Onboarding flag helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _markOnboarded(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiPut('/preferences/onboarding', { onboarded: true });
|
||||||
|
// Suppress tooltip tour too — wizard owns the first-run experience
|
||||||
|
suppressGettingStartedTour();
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: UI already moved on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Wizard step navigation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _stepIndex(step: WizardStep): number {
|
||||||
|
return STEPS.indexOf(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wizardNext(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const step = _state.step;
|
||||||
|
|
||||||
|
if (step === 'welcome') {
|
||||||
|
_state.step = 'device';
|
||||||
|
_renderStep();
|
||||||
|
_startDiscovery();
|
||||||
|
} else if (step === 'device') {
|
||||||
|
if (!_state.deviceId) {
|
||||||
|
_setError(t('wizard.error.no_device'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_state.step = 'display';
|
||||||
|
_renderStep();
|
||||||
|
await _loadDisplays();
|
||||||
|
} else if (step === 'display') {
|
||||||
|
_state.step = 'scaffold';
|
||||||
|
_renderStep();
|
||||||
|
await _runScaffold();
|
||||||
|
} else if (step === 'calibrate') {
|
||||||
|
// "Skip calibration" path — move to start.
|
||||||
|
// Mark busy BEFORE the await so a fast second tap on Skip/Next can't
|
||||||
|
// re-enter wizardNext (gated at the top) and fire a second teardown +
|
||||||
|
// start during the up-to-10s unmount await. _startOutput() manages
|
||||||
|
// busy itself afterward.
|
||||||
|
_state.busy = true;
|
||||||
|
_renderStep();
|
||||||
|
// Await teardown first: unmountAutoCalibration() POSTs session/stop, which
|
||||||
|
// restores the device's prior target. Starting output before that completes
|
||||||
|
// would let the stop clobber the just-started target.
|
||||||
|
await unmountAutoCalibration();
|
||||||
|
if (!_state) return;
|
||||||
|
_state.step = 'start';
|
||||||
|
_renderStep();
|
||||||
|
await _startOutput();
|
||||||
|
} else if (step === 'start') {
|
||||||
|
_state.step = 'done';
|
||||||
|
_renderStep();
|
||||||
|
} else if (step === 'done') {
|
||||||
|
void closeSetupWizard();
|
||||||
|
await _markOnboarded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardBack(): void {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const idx = _stepIndex(_state.step);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
// Back from calibrate: unmount the autocal component
|
||||||
|
if (_state.step === 'calibrate') {
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
}
|
||||||
|
_state.step = STEPS[idx - 1];
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardSkip(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
void closeSetupWizard();
|
||||||
|
void _markOnboarded();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: device discovery
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _startDiscovery(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.discoveredDevices = [];
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
// Omit device_type so the backend scans every provider (WLED, Adalight,
|
||||||
|
// DDP, OpenRGB, BLE, …) in parallel — not just WLED.
|
||||||
|
const data = await apiGet<{ devices?: DiscoveredDevice[] }>('/devices/discover?timeout=3');
|
||||||
|
_state.discoveredDevices = data.devices || [];
|
||||||
|
} catch {
|
||||||
|
_state.discoveredDevices = [];
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch device step to manual-entry mode. */
|
||||||
|
export function wizardShowManual(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.manualMode = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardHideManual(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.manualMode = false;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User clicked a discovered device — create it via POST /devices. */
|
||||||
|
export async function wizardSelectDiscovered(url: string, name: string, device_type: string): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name,
|
||||||
|
device_type,
|
||||||
|
url,
|
||||||
|
led_count: 60,
|
||||||
|
};
|
||||||
|
const device = await apiPost<Device>('/devices', body,
|
||||||
|
{ errorMessage: t('wizard.error.device_create_failed') });
|
||||||
|
_state.deviceId = device.id;
|
||||||
|
_state.deviceName = device.name;
|
||||||
|
devicesCache.invalidate();
|
||||||
|
_state.step = 'display';
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
await _loadDisplays();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_setError(err instanceof Error ? err.message : t('wizard.error.device_create_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual device form submit. */
|
||||||
|
export async function wizardAddManualDevice(event: Event): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const nameEl = document.getElementById('wizard-device-name') as HTMLInputElement | null;
|
||||||
|
const urlEl = document.getElementById('wizard-device-url') as HTMLInputElement | null;
|
||||||
|
const ledEl = document.getElementById('wizard-device-led-count') as HTMLInputElement | null;
|
||||||
|
const name = nameEl?.value.trim() || '';
|
||||||
|
const url = urlEl?.value.trim() || '';
|
||||||
|
const ledCount = parseInt(ledEl?.value || '60', 10) || 60;
|
||||||
|
|
||||||
|
if (!name) { _setError(t('wizard.error.device_name_required')); return; }
|
||||||
|
if (!url) { _setError(t('wizard.error.device_url_required')); return; }
|
||||||
|
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
const device = await apiPost<Device>('/devices', {
|
||||||
|
name, url, device_type: 'wled', led_count: ledCount,
|
||||||
|
}, { errorMessage: t('wizard.error.device_create_failed') });
|
||||||
|
_state.deviceId = device.id;
|
||||||
|
_state.deviceName = device.name;
|
||||||
|
devicesCache.invalidate();
|
||||||
|
_state.step = 'display';
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
await _loadDisplays();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_setError(err instanceof Error ? err.message : t('wizard.error.device_create_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User selected an already-existing device from the cache. */
|
||||||
|
export function wizardUseExistingDevice(deviceId: string, deviceName: string): void {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.deviceId = deviceId;
|
||||||
|
_state.deviceName = deviceName;
|
||||||
|
_state.step = 'display';
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
void _loadDisplays();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: display selection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _loadDisplays(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
await displaysCache.fetch();
|
||||||
|
} catch {
|
||||||
|
// Fall through — render will show a fallback
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardSelectDisplay(index: number, displayName: string, skipRender = false): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.displayIndex = index;
|
||||||
|
_state.displayName = displayName;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
// The manual display-index <input> fallback passes skipRender=true: a full
|
||||||
|
// _renderStep() rebuilds the container via innerHTML, destroying the input
|
||||||
|
// and losing focus/caret on every keystroke (unusable on Android TV WebView).
|
||||||
|
if (!skipRender) _renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: scaffold
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _runScaffold(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
const result = await apiPost<ScaffoldResult>('/setup/scaffold', {
|
||||||
|
device_id: _state.deviceId,
|
||||||
|
display_index: _state.displayIndex,
|
||||||
|
calibration: null,
|
||||||
|
}, { errorMessage: t('wizard.error.scaffold_failed') });
|
||||||
|
_state.scaffoldResult = result;
|
||||||
|
_state.busy = false;
|
||||||
|
_state.step = 'calibrate';
|
||||||
|
_renderStep();
|
||||||
|
// Mount the auto-calibration component inside the calibrate step container
|
||||||
|
const container = document.getElementById('wizard-calibrate-container');
|
||||||
|
if (container) {
|
||||||
|
await mountAutoCalibration({
|
||||||
|
container,
|
||||||
|
cssId: result.color_strip_source_id,
|
||||||
|
deviceId: _state.deviceId,
|
||||||
|
onComplete: () => {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.step = 'start';
|
||||||
|
_renderStep();
|
||||||
|
void _startOutput();
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.step = 'start';
|
||||||
|
_renderStep();
|
||||||
|
void _startOutput();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_setError(err instanceof Error ? err.message : t('wizard.error.scaffold_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: start output
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _startOutput(): Promise<void> {
|
||||||
|
if (!_state?.scaffoldResult) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
await apiPost<unknown>(`/output-targets/${_state.scaffoldResult.output_target_id}/start`, {},
|
||||||
|
{ errorMessage: t('wizard.error.start_failed') });
|
||||||
|
outputTargetsCache.invalidate();
|
||||||
|
_state.busy = false;
|
||||||
|
_state.step = 'done';
|
||||||
|
_renderStep();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
// Non-fatal: still show done step but surface the error
|
||||||
|
showToast(err instanceof Error ? err.message : t('wizard.error.start_failed'), 'warning');
|
||||||
|
_state.step = 'done';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Internal helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _setError(msg: string): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.errorMsg = msg;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleWizardClose(): void {
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
_state = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Rendering
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderStep(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
const container = document.getElementById('wizard-step-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
_renderProgressBar();
|
||||||
|
|
||||||
|
const html = _buildStepHtml(_state);
|
||||||
|
container.innerHTML = html;
|
||||||
|
_attachStepListeners(_state.step);
|
||||||
|
_focusFirstControl(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move focus to the step's first focusable control after a re-render so D-pad /
|
||||||
|
* TV WebView users don't land on a node that was just removed. Skipped on the
|
||||||
|
* very first render so it doesn't fight the Modal's own initial autofocus.
|
||||||
|
*/
|
||||||
|
function _focusFirstControl(container: HTMLElement): void {
|
||||||
|
if (_firstRender) {
|
||||||
|
_firstRender = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = container.querySelector(
|
||||||
|
'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])'
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (el && el.offsetParent !== null) el.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderProgressBar(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
const bar = document.getElementById('wizard-progress-bar');
|
||||||
|
const labels = document.getElementById('wizard-progress-labels');
|
||||||
|
if (!bar || !labels) return;
|
||||||
|
|
||||||
|
const currentIdx = _stepIndex(_state.step);
|
||||||
|
// Progress bar shows steps 1-6 (skip 'done' which is the finish state)
|
||||||
|
const visibleSteps: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start'];
|
||||||
|
const total = visibleSteps.length;
|
||||||
|
const activeIdx = visibleSteps.indexOf(_state.step);
|
||||||
|
const pct = activeIdx < 0 ? 100 : Math.round(((activeIdx) / (total - 1)) * 100);
|
||||||
|
|
||||||
|
bar.innerHTML = `
|
||||||
|
<div class="wizard-progress-track">
|
||||||
|
<div class="wizard-progress-fill" style="width:${pct}%"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const stepLabels = visibleSteps.map((s, i) => {
|
||||||
|
const done = currentIdx > STEPS.indexOf(s);
|
||||||
|
const active = s === _state!.step;
|
||||||
|
const cls = done ? 'wizard-pip wizard-pip--done' : active ? 'wizard-pip wizard-pip--active' : 'wizard-pip';
|
||||||
|
return `<span class="${cls}" title="${t(`wizard.step.${s}`)}">${done ? ICON_CHECK : String(i + 1)}</span>`;
|
||||||
|
}).join('');
|
||||||
|
labels.innerHTML = stepLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildStepHtml(state: WizardState): string {
|
||||||
|
switch (state.step) {
|
||||||
|
case 'welcome': return _buildWelcomeStep();
|
||||||
|
case 'device': return _buildDeviceStep(state);
|
||||||
|
case 'display': return _buildDisplayStep(state);
|
||||||
|
case 'scaffold': return _buildScaffoldStep(state);
|
||||||
|
case 'calibrate':return _buildCalibrateStep(state);
|
||||||
|
case 'start': return _buildStartStep(state);
|
||||||
|
case 'done': return _buildDoneStep(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _errorBanner(msg: string): string {
|
||||||
|
if (!msg) return '';
|
||||||
|
return `<div class="wizard-error">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
|
||||||
|
<span>${msg}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildWelcomeStep(): string {
|
||||||
|
return `<div class="wizard-step wizard-step--welcome">
|
||||||
|
<div class="wizard-welcome-icon">${ICON_SPARKLES}</div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.welcome.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.welcome.desc')}</p>
|
||||||
|
<ul class="wizard-welcome-list">
|
||||||
|
<li>${ICON_DEVICE}<span>${t('wizard.welcome.item1')}</span></li>
|
||||||
|
<li>${ICON_MONITOR}<span>${t('wizard.welcome.item2')}</span></li>
|
||||||
|
<li>${ICON_CALIBRATION}<span>${t('wizard.welcome.item3')}</span></li>
|
||||||
|
<li>${ICON_START}<span>${t('wizard.welcome.item4')}</span></li>
|
||||||
|
</ul>
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardSkip()">${t('wizard.skip')}</button>
|
||||||
|
<button class="btn btn-primary" onclick="wizardNext()">${t('wizard.start')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildDeviceStep(state: WizardState): string {
|
||||||
|
const existingDevices: Device[] = devicesCache.data || [];
|
||||||
|
|
||||||
|
let discoveryHtml = '';
|
||||||
|
if (state.busy && state.discoveredDevices.length === 0) {
|
||||||
|
discoveryHtml = `<div class="wizard-discovery-scanning">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>${t('wizard.device.scanning')}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (state.discoveredDevices.length > 0) {
|
||||||
|
discoveryHtml = `<div class="wizard-discovery-list">` +
|
||||||
|
state.discoveredDevices.map(d => `
|
||||||
|
<button class="wizard-discovery-item" onclick="wizardSelectDiscovered('${_esc(d.url)}','${_esc(d.name)}','${_esc(d.device_type)}')">
|
||||||
|
<span class="wizard-discovery-icon">${getDeviceTypeIcon(d.device_type)}</span>
|
||||||
|
<span class="wizard-discovery-details">
|
||||||
|
<span class="wizard-discovery-name">${_esc(d.name)}</span>
|
||||||
|
<span class="wizard-discovery-url">${_esc(d.url)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="wizard-discovery-badge">${_esc(d.device_type.toUpperCase())}</span>
|
||||||
|
</button>`).join('') +
|
||||||
|
`</div>`;
|
||||||
|
} else {
|
||||||
|
discoveryHtml = `<div class="wizard-discovery-empty">
|
||||||
|
<span>${t('wizard.device.none_found')}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingHtml = '';
|
||||||
|
if (existingDevices.length > 0) {
|
||||||
|
existingHtml = `<div class="wizard-section-label">${t('wizard.device.existing')}</div>
|
||||||
|
<div class="wizard-discovery-list">` +
|
||||||
|
existingDevices.map(d => `
|
||||||
|
<button class="wizard-discovery-item" onclick="wizardUseExistingDevice('${_esc(d.id)}','${_esc(d.name)}')">
|
||||||
|
<span class="wizard-discovery-icon">${getDeviceTypeIcon(d.device_type)}</span>
|
||||||
|
<span class="wizard-discovery-details">
|
||||||
|
<span class="wizard-discovery-name">${_esc(d.name)}</span>
|
||||||
|
<span class="wizard-discovery-url">${_esc(d.url)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="wizard-discovery-badge">${_esc(d.device_type.toUpperCase())}</span>
|
||||||
|
</button>`).join('') +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manualHtml = '';
|
||||||
|
if (state.manualMode) {
|
||||||
|
manualHtml = `<form id="wizard-manual-form" onsubmit="wizardAddManualDevice(event)">
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.device.manual.name')}</label>
|
||||||
|
<input id="wizard-device-name" class="form-input" type="text" placeholder="${t('wizard.device.manual.name_placeholder')}" required>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.device.manual.url')}</label>
|
||||||
|
<input id="wizard-device-url" class="form-input" type="text" placeholder="http://192.168.1.x" required>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.device.manual.led_count')}</label>
|
||||||
|
<input id="wizard-device-led-count" class="form-input" type="number" min="1" max="1000" value="60">
|
||||||
|
</div>
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="wizardHideManual()">${t('common.back')}</button>
|
||||||
|
<button type="submit" class="btn btn-primary"${state.busy ? ' disabled' : ''}>
|
||||||
|
${state.busy ? `<div class="btn-spinner"></div>` : ''}${t('wizard.device.manual.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
} else {
|
||||||
|
manualHtml = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="wizard-step">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon">${ICON_DEVICE}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.device.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.device.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${!state.manualMode ? `
|
||||||
|
<div class="wizard-discovery-section">
|
||||||
|
<div class="wizard-section-label wizard-section-label--scan">
|
||||||
|
${t('wizard.device.discovered')}
|
||||||
|
<button class="wizard-scan-btn" onclick="wizardRescan()"${state.busy ? ' disabled' : ''}>
|
||||||
|
${ICON_SEARCH} ${t('wizard.device.rescan')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${discoveryHtml}
|
||||||
|
</div>
|
||||||
|
${existingHtml}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardSkip()">${t('wizard.skip')}</button>
|
||||||
|
<button class="btn btn-secondary" onclick="wizardShowManual()">
|
||||||
|
${ICON_PLUS} ${t('wizard.device.manual.title')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : manualHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildDisplayStep(state: WizardState): string {
|
||||||
|
const displays: Display[] = displaysCache.data ?? [];
|
||||||
|
|
||||||
|
let listHtml = '';
|
||||||
|
if (state.busy && displays.length === 0) {
|
||||||
|
listHtml = `<div class="wizard-discovery-scanning">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>${t('wizard.display.loading')}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (displays.length === 0) {
|
||||||
|
// Fallback: offer a manual index input
|
||||||
|
listHtml = `<div class="wizard-display-fallback">
|
||||||
|
<p class="wizard-step-desc">${t('wizard.display.no_displays')}</p>
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.display.manual_index')}</label>
|
||||||
|
<input id="wizard-display-index-manual" class="form-input" type="number"
|
||||||
|
min="0" max="63" value="${state.displayIndex}"
|
||||||
|
oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value, true)">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
listHtml = `<div class="wizard-display-list">` +
|
||||||
|
displays.map(d => {
|
||||||
|
const active = d.index === state.displayIndex;
|
||||||
|
return `<button class="wizard-display-item${active ? ' wizard-display-item--active' : ''}"
|
||||||
|
onclick="wizardSelectDisplay(${d.index}, '${_esc(d.name)}')">
|
||||||
|
<span class="wizard-display-icon">${ICON_MONITOR}</span>
|
||||||
|
<span class="wizard-display-details">
|
||||||
|
<span class="wizard-display-name">${_esc(d.name)}</span>
|
||||||
|
<span class="wizard-display-dims">${d.width} × ${d.height}${d.is_primary ? ' · ' + t('wizard.display.primary') : ''}</span>
|
||||||
|
</span>
|
||||||
|
${active ? `<span class="wizard-display-check">${ICON_CHECK}</span>` : ''}
|
||||||
|
</button>`;
|
||||||
|
}).join('') +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="wizard-step">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon">${ICON_MONITOR}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.display.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.display.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${listHtml}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardBack()">${t('common.back')}</button>
|
||||||
|
<button class="btn btn-primary" onclick="wizardNext()"${state.busy ? ' disabled' : ''}>
|
||||||
|
${t('wizard.display.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildScaffoldStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--scaffold">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon${state.scaffoldResult ? ' wizard-step-icon--ok' : ''}">${state.scaffoldResult ? ICON_OK : ICON_SPARKLES}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.scaffold.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${state.busy ? t('wizard.scaffold.building') : state.scaffoldResult ? t('wizard.scaffold.done') : t('wizard.scaffold.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${state.busy ? `<div class="wizard-scaffold-progress">
|
||||||
|
<div class="wizard-scaffold-spinner"><div class="loading-spinner"></div></div>
|
||||||
|
<span class="wizard-scaffold-label">${t('wizard.scaffold.building')}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildCalibrateStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--calibrate">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon">${ICON_CALIBRATION}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.calibrate.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.calibrate.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- auto-calibration.ts mounts here -->
|
||||||
|
<div id="wizard-calibrate-container" class="wizard-calibrate-container"></div>
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardNext()">${t('wizard.calibrate.skip')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildStartStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--start">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon${!state.busy && !state.errorMsg ? ' wizard-step-icon--ok' : ''}">${START_STEP_ICON(state)}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.start.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${state.busy ? t('wizard.start.starting') : state.errorMsg ? t('wizard.start.failed') : t('wizard.start.done')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${state.busy ? `<div class="wizard-scaffold-progress">
|
||||||
|
<div class="wizard-scaffold-spinner"><div class="loading-spinner"></div></div>
|
||||||
|
<span class="wizard-scaffold-label">${t('wizard.start.starting')}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function START_STEP_ICON(state: WizardState): string {
|
||||||
|
if (state.busy) return ICON_START;
|
||||||
|
if (state.errorMsg) return ICON_START;
|
||||||
|
return ICON_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildDoneStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--done">
|
||||||
|
<div class="wizard-done-icon">${ICON_ROCKET_ICON}</div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.done.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.done.desc')}</p>
|
||||||
|
${state.scaffoldResult ? `<div class="wizard-done-summary">
|
||||||
|
<div class="wizard-done-item">
|
||||||
|
<span class="wizard-done-label">${t('wizard.done.device')}</span>
|
||||||
|
<span class="wizard-done-value">${_esc(state.deviceName)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-done-item">
|
||||||
|
<span class="wizard-done-label">${t('wizard.done.display')}</span>
|
||||||
|
<span class="wizard-done-value">${_esc(state.displayName || (t('wizard.display.index_prefix') + ' ' + String(state.displayIndex)))}</span>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="wizard-footer wizard-footer--done">
|
||||||
|
<button class="btn btn-primary" onclick="wizardFinish()">${t('wizard.done.finish')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _attachStepListeners(_step: WizardStep): void {
|
||||||
|
// The manual device form uses onsubmit="wizardAddManualDevice(event)" inline —
|
||||||
|
// no duplicate addEventListener needed here.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Re-scan
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function wizardRescan(): void {
|
||||||
|
if (!_state || _state.step !== 'device') return;
|
||||||
|
_startDiscovery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Finish
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function wizardFinish(): void {
|
||||||
|
void closeSetupWizard();
|
||||||
|
void _markOnboarded();
|
||||||
|
// Reload targets tab so the new target appears immediately
|
||||||
|
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Utility
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _esc(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@@ -171,6 +171,8 @@ class TargetEditorModal extends Modal {
|
|||||||
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
|
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
|
||||||
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
|
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
|
||||||
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
|
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
|
||||||
|
max_milliamps: (document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value,
|
||||||
|
milliamps_per_led: (document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value,
|
||||||
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -181,8 +183,13 @@ const targetEditorModal = new TargetEditorModal();
|
|||||||
function _protocolBadge(device: any, target: any) {
|
function _protocolBadge(device: any, target: any) {
|
||||||
const dt = device?.device_type;
|
const dt = device?.device_type;
|
||||||
if (!dt || dt === 'wled') {
|
if (!dt || dt === 'wled') {
|
||||||
const proto = target.protocol === 'http' ? 'HTTP' : 'DDP';
|
const wledMap: Record<string, [string, string]> = {
|
||||||
return `${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${proto}`;
|
http: [ICON_GLOBE, 'HTTP'],
|
||||||
|
udp: [ICON_RADIO, 'WLED UDP'],
|
||||||
|
ddp: [ICON_RADIO, 'DDP'],
|
||||||
|
};
|
||||||
|
const [icon, label] = wledMap[target.protocol] || wledMap.ddp;
|
||||||
|
return `${icon} ${label}`;
|
||||||
}
|
}
|
||||||
const map = {
|
const map = {
|
||||||
openrgb: [ICON_PALETTE, 'OpenRGB SDK'],
|
openrgb: [ICON_PALETTE, 'OpenRGB SDK'],
|
||||||
@@ -311,10 +318,11 @@ function _ensureProtocolIconSelect() {
|
|||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const items = [
|
const items = [
|
||||||
{ value: 'ddp', icon: _pIcon(P.radio), label: t('targets.protocol.ddp'), desc: t('targets.protocol.ddp.desc') },
|
{ value: 'ddp', icon: _pIcon(P.radio), label: t('targets.protocol.ddp'), desc: t('targets.protocol.ddp.desc') },
|
||||||
|
{ value: 'udp', icon: _pIcon(P.radio), label: t('targets.protocol.udp'), desc: t('targets.protocol.udp.desc') },
|
||||||
{ value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') },
|
{ value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') },
|
||||||
];
|
];
|
||||||
if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; }
|
if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; }
|
||||||
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
|
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 3 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function _ensureBrightnessWidget(): BindableScalarWidget {
|
function _ensureBrightnessWidget(): BindableScalarWidget {
|
||||||
@@ -401,6 +409,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
|||||||
|
|
||||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
|
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
|
||||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
|
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
|
||||||
|
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = String(target.max_milliamps ?? 0);
|
||||||
|
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = String(target.milliamps_per_led ?? 55);
|
||||||
|
|
||||||
_populateCssDropdown(target.color_strip_source_id || '');
|
_populateCssDropdown(target.color_strip_source_id || '');
|
||||||
_ensureBrightnessWidget().setValue(target.brightness ?? 1.0);
|
_ensureBrightnessWidget().setValue(target.brightness ?? 1.0);
|
||||||
@@ -419,6 +429,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
|||||||
|
|
||||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
|
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
|
||||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
|
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
|
||||||
|
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = String(cloneData.max_milliamps ?? 0);
|
||||||
|
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = String(cloneData.milliamps_per_led ?? 55);
|
||||||
|
|
||||||
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||||
_ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0);
|
_ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0);
|
||||||
@@ -435,6 +447,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
|||||||
|
|
||||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
|
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
|
||||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
|
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
|
||||||
|
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = '0';
|
||||||
|
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = '55';
|
||||||
|
|
||||||
_populateCssDropdown('');
|
_populateCssDropdown('');
|
||||||
_ensureBrightnessWidget().setValue(1.0);
|
_ensureBrightnessWidget().setValue(1.0);
|
||||||
@@ -515,6 +529,8 @@ export async function saveTargetEditor() {
|
|||||||
|
|
||||||
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
|
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
|
||||||
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
|
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
|
||||||
|
const maxMilliamps = Math.max(0, Math.round(Number((document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value) || 0));
|
||||||
|
const milliampsPerLed = Math.max(1, Math.round(Number((document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value) || 55));
|
||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
name,
|
name,
|
||||||
@@ -526,6 +542,8 @@ export async function saveTargetEditor() {
|
|||||||
keepalive_interval: standbyInterval,
|
keepalive_interval: standbyInterval,
|
||||||
adaptive_fps: adaptiveFps,
|
adaptive_fps: adaptiveFps,
|
||||||
protocol,
|
protocol,
|
||||||
|
max_milliamps: maxMilliamps,
|
||||||
|
milliamps_per_led: milliampsPerLed,
|
||||||
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
|
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,19 @@ const calibrationTutorialSteps: TutorialStep[] = [
|
|||||||
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
|
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const TOUR_KEY = 'tour_completed';
|
export const TOUR_KEY = 'tour_completed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppress the getting-started tour for this session AND permanently.
|
||||||
|
*
|
||||||
|
* Called by the setup wizard when it takes over the first-run experience so
|
||||||
|
* the tour never double-fires after the wizard completes. Setting the
|
||||||
|
* localStorage key mirrors what `onClose` would do when the tour finishes
|
||||||
|
* naturally.
|
||||||
|
*/
|
||||||
|
export function suppressGettingStartedTour(): void {
|
||||||
|
localStorage.setItem(TOUR_KEY, '1');
|
||||||
|
}
|
||||||
|
|
||||||
const gettingStartedSteps: TutorialStep[] = [
|
const gettingStartedSteps: TutorialStep[] = [
|
||||||
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
|
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
|
||||||
|
|||||||
+33
@@ -60,6 +60,21 @@ interface Window {
|
|||||||
selectDisplay: (...args: any[]) => any;
|
selectDisplay: (...args: any[]) => any;
|
||||||
formatDisplayLabel: (...args: any[]) => any;
|
formatDisplayLabel: (...args: any[]) => any;
|
||||||
|
|
||||||
|
// ─── Setup Wizard ───
|
||||||
|
openSetupWizard: () => void;
|
||||||
|
closeSetupWizard: () => void;
|
||||||
|
wizardNext: () => Promise<void>;
|
||||||
|
wizardBack: () => void;
|
||||||
|
wizardSkip: () => void;
|
||||||
|
wizardFinish: () => void;
|
||||||
|
wizardShowManual: () => void;
|
||||||
|
wizardHideManual: () => void;
|
||||||
|
wizardRescan: () => void;
|
||||||
|
wizardSelectDiscovered: (url: string, name: string, device_type: string) => Promise<void>;
|
||||||
|
wizardAddManualDevice: (event: Event) => Promise<void>;
|
||||||
|
wizardUseExistingDevice: (deviceId: string, deviceName: string) => void;
|
||||||
|
wizardSelectDisplay: (index: number, displayName: string) => void;
|
||||||
|
|
||||||
// ─── Tutorials ───
|
// ─── Tutorials ───
|
||||||
startCalibrationTutorial: (...args: any[]) => any;
|
startCalibrationTutorial: (...args: any[]) => any;
|
||||||
startDeviceTutorial: (...args: any[]) => any;
|
startDeviceTutorial: (...args: any[]) => any;
|
||||||
@@ -354,6 +369,24 @@ startTargetOverlay: (...args: any[]) => any;
|
|||||||
toggleTestEdge: (...args: any[]) => any;
|
toggleTestEdge: (...args: any[]) => any;
|
||||||
showCSSCalibration: (...args: any[]) => any;
|
showCSSCalibration: (...args: any[]) => any;
|
||||||
toggleCalibrationOverlay: (...args: any[]) => any;
|
toggleCalibrationOverlay: (...args: any[]) => any;
|
||||||
|
openAutoCalFromCalibration: (...args: any[]) => any;
|
||||||
|
|
||||||
|
// ─── Auto-Calibration wizard ───
|
||||||
|
showAutoCalibration: (...args: any[]) => any;
|
||||||
|
closeAutoCalModal: (...args: any[]) => any;
|
||||||
|
autoCalSelectDevice: (...args: any[]) => any;
|
||||||
|
autoCalSetCorner: (...args: any[]) => any;
|
||||||
|
autoCalSetDirection: (...args: any[]) => any;
|
||||||
|
autoCalBackToCorner: (...args: any[]) => any;
|
||||||
|
autoCalBackToDirection: (...args: any[]) => any;
|
||||||
|
autoCalSweepForward: (...args: any[]) => any;
|
||||||
|
autoCalSweepBack: (...args: any[]) => any;
|
||||||
|
autoCalMarkCorner: (...args: any[]) => any;
|
||||||
|
autoCalSolve: (...args: any[]) => any;
|
||||||
|
autoCalSave: (...args: any[]) => any;
|
||||||
|
autoCalCancel: (...args: any[]) => any;
|
||||||
|
mountAutoCalibration: (...args: any[]) => any;
|
||||||
|
unmountAutoCalibration: (...args: any[]) => any;
|
||||||
|
|
||||||
// ─── Advanced Calibration ───
|
// ─── Advanced Calibration ───
|
||||||
showAdvancedCalibration: (...args: any[]) => any;
|
showAdvancedCalibration: (...args: any[]) => any;
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ export type {
|
|||||||
ScenePreset,
|
ScenePreset,
|
||||||
ScenePresetListResponse,
|
ScenePresetListResponse,
|
||||||
} from './types/scene-preset.ts';
|
} from './types/scene-preset.ts';
|
||||||
|
export type {
|
||||||
|
PlaylistItem,
|
||||||
|
ScenePlaylist,
|
||||||
|
PlaylistRuntimeState,
|
||||||
|
ScenePlaylistListResponse,
|
||||||
|
} from './types/scene-playlist.ts';
|
||||||
|
|
||||||
// ── Sync Clock ────────────────────────────────────────────────
|
// ── Sync Clock ────────────────────────────────────────────────
|
||||||
export type { SyncClock, SyncClockListResponse } from './types/sync-clock.ts';
|
export type { SyncClock, SyncClockListResponse } from './types/sync-clock.ts';
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export interface LedOutputTarget extends OutputTargetBase {
|
|||||||
min_brightness_threshold?: BindableFloat;
|
min_brightness_threshold?: BindableFloat;
|
||||||
adaptive_fps: boolean;
|
adaptive_fps: boolean;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
|
max_milliamps?: number;
|
||||||
|
milliamps_per_led?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HALightSourceKind = 'css' | 'color_vs';
|
export type HALightSourceKind = 'css' | 'color_vs';
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Scene playlist shapes — an ordered, timed sequence of scene presets that
|
||||||
|
* auto-cycles, activating each preset and holding it for its dwell duration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PlaylistItem {
|
||||||
|
scene_preset_id: string;
|
||||||
|
duration_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenePlaylist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
items: PlaylistItem[];
|
||||||
|
loop: boolean;
|
||||||
|
shuffle: boolean;
|
||||||
|
order: number;
|
||||||
|
tags: string[];
|
||||||
|
icon?: string;
|
||||||
|
icon_color?: string;
|
||||||
|
is_running?: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistRuntimeState {
|
||||||
|
is_running: boolean;
|
||||||
|
playlist_id: string | null;
|
||||||
|
playlist_name: string | null;
|
||||||
|
current_index: number;
|
||||||
|
item_count: number;
|
||||||
|
current_preset_id: string | null;
|
||||||
|
started_at: string | null;
|
||||||
|
step_started_at: string | null;
|
||||||
|
step_duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenePlaylistListResponse {
|
||||||
|
playlists: ScenePlaylist[];
|
||||||
|
count: number;
|
||||||
|
state: PlaylistRuntimeState;
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export interface PostprocessingTemplate {
|
|||||||
description?: string;
|
description?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
icon_color?: string;
|
icon_color?: string;
|
||||||
|
is_builtin?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -638,7 +638,23 @@
|
|||||||
"calibration.skip_end": "Skip LEDs (End):",
|
"calibration.skip_end": "Skip LEDs (End):",
|
||||||
"calibration.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)",
|
"calibration.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)",
|
||||||
"calibration.border_width": "Border (px):",
|
"calibration.border_width": "Border (px):",
|
||||||
|
"calibration.roi": "Capture region (%):",
|
||||||
|
"calibration.roi.hint": "Sample only this sub-rectangle of the screen so a taskbar, HUD, or black bars don't pollute the border colours. Full frame = X/Y 0, Width/Height 100.",
|
||||||
|
"calibration.roi.x": "X (%)",
|
||||||
|
"calibration.roi.y": "Y (%)",
|
||||||
|
"calibration.roi.width": "Width (%)",
|
||||||
|
"calibration.roi.height": "Height (%)",
|
||||||
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
||||||
|
"calibration.edge.top": "Toggle top edge test LEDs",
|
||||||
|
"calibration.edge.right": "Toggle right edge test LEDs",
|
||||||
|
"calibration.edge.bottom": "Toggle bottom edge test LEDs",
|
||||||
|
"calibration.edge.left": "Toggle left edge test LEDs",
|
||||||
|
"calibration.corner.top_left": "Set start position: top left",
|
||||||
|
"calibration.corner.top_right": "Set start position: top right",
|
||||||
|
"calibration.corner.bottom_right": "Set start position: bottom right",
|
||||||
|
"calibration.corner.bottom_left": "Set start position: bottom left",
|
||||||
|
"calibration.direction.toggle": "Toggle direction",
|
||||||
|
"calibration.edge_inputs.toggle": "Toggle edge LED inputs",
|
||||||
"calibration.button.cancel": "Cancel",
|
"calibration.button.cancel": "Cancel",
|
||||||
"calibration.button.save": "Save",
|
"calibration.button.save": "Save",
|
||||||
"calibration.saved": "Calibration saved",
|
"calibration.saved": "Calibration saved",
|
||||||
@@ -668,6 +684,7 @@
|
|||||||
"common.none_own_speed": "None (no sync)",
|
"common.none_own_speed": "None (no sync)",
|
||||||
"common.undo": "Undo",
|
"common.undo": "Undo",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
|
"common.back": "Back",
|
||||||
"common.apply": "Apply",
|
"common.apply": "Apply",
|
||||||
"common.start": "START",
|
"common.start": "START",
|
||||||
"common.stop": "STOP",
|
"common.stop": "STOP",
|
||||||
@@ -784,6 +801,7 @@
|
|||||||
"device.icon.entity.ha_source": "Home Assistant source",
|
"device.icon.entity.ha_source": "Home Assistant source",
|
||||||
"device.icon.entity.automation": "Automation",
|
"device.icon.entity.automation": "Automation",
|
||||||
"device.icon.entity.scene_preset": "Scene preset",
|
"device.icon.entity.scene_preset": "Scene preset",
|
||||||
|
"device.icon.entity.scene_playlist": "Playlist",
|
||||||
"device.icon.entity.sync_clock": "Sync clock",
|
"device.icon.entity.sync_clock": "Sync clock",
|
||||||
"device.icon.entity.game_integration": "Game integration",
|
"device.icon.entity.game_integration": "Game integration",
|
||||||
"device.icon.entity.audio_processing_template": "Audio processing template",
|
"device.icon.entity.audio_processing_template": "Audio processing template",
|
||||||
@@ -1101,6 +1119,7 @@
|
|||||||
"dashboard.failed": "Failed to load dashboard",
|
"dashboard.failed": "Failed to load dashboard",
|
||||||
"dashboard.section.automations": "Automations",
|
"dashboard.section.automations": "Automations",
|
||||||
"dashboard.section.scenes": "Scene Presets",
|
"dashboard.section.scenes": "Scene Presets",
|
||||||
|
"dashboard.section.playlists": "Playlists",
|
||||||
"dashboard.section.sync_clocks": "Sync Clocks",
|
"dashboard.section.sync_clocks": "Sync Clocks",
|
||||||
"dashboard.targets": "Targets",
|
"dashboard.targets": "Targets",
|
||||||
"dashboard.section.performance": "System Performance",
|
"dashboard.section.performance": "System Performance",
|
||||||
@@ -1235,6 +1254,17 @@
|
|||||||
"automations.rule.time_of_day.start_time": "Start Time:",
|
"automations.rule.time_of_day.start_time": "Start Time:",
|
||||||
"automations.rule.time_of_day.end_time": "End Time:",
|
"automations.rule.time_of_day.end_time": "End Time:",
|
||||||
"automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.",
|
"automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.",
|
||||||
|
"automations.rule.time_of_day.days": "Active days",
|
||||||
|
"automations.rule.time_of_day.days_hint": "Leave all unselected for every day. Overnight windows count toward the day they start on.",
|
||||||
|
"automations.rule.time_of_day.timezone": "Timezone",
|
||||||
|
"automations.rule.time_of_day.timezone.placeholder": "Server local (e.g. Europe/Berlin)",
|
||||||
|
"weekday.short.0": "Mon",
|
||||||
|
"weekday.short.1": "Tue",
|
||||||
|
"weekday.short.2": "Wed",
|
||||||
|
"weekday.short.3": "Thu",
|
||||||
|
"weekday.short.4": "Fri",
|
||||||
|
"weekday.short.5": "Sat",
|
||||||
|
"weekday.short.6": "Sun",
|
||||||
"automations.rule.system_idle": "System Idle",
|
"automations.rule.system_idle": "System Idle",
|
||||||
"automations.rule.system_idle.desc": "User idle/active",
|
"automations.rule.system_idle.desc": "User idle/active",
|
||||||
"automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):",
|
"automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):",
|
||||||
@@ -1334,6 +1364,50 @@
|
|||||||
"scenes.error.delete_failed": "Failed to delete scene",
|
"scenes.error.delete_failed": "Failed to delete scene",
|
||||||
"scenes.cloned": "Scene cloned",
|
"scenes.cloned": "Scene cloned",
|
||||||
"scenes.error.clone_failed": "Failed to clone scene",
|
"scenes.error.clone_failed": "Failed to clone scene",
|
||||||
|
"playlists.title": "Playlists",
|
||||||
|
"playlists.add": "New Playlist",
|
||||||
|
"playlists.edit": "Edit Playlist",
|
||||||
|
"playlists.name": "Name:",
|
||||||
|
"playlists.name.placeholder": "My Playlist",
|
||||||
|
"playlists.description": "Description:",
|
||||||
|
"playlists.description.hint": "Optional description of what this playlist does",
|
||||||
|
"playlists.section.playback": "Playback",
|
||||||
|
"playlists.loop": "Loop:",
|
||||||
|
"playlists.loop.hint": "Restart from the first scene after the last one; off plays through once and stops",
|
||||||
|
"playlists.shuffle": "Shuffle:",
|
||||||
|
"playlists.shuffle.hint": "Randomise the scene order at the start of every cycle",
|
||||||
|
"playlists.scenes": "Scenes:",
|
||||||
|
"playlists.scenes.hint": "The scene presets this playlist cycles through, each held for its own duration",
|
||||||
|
"playlists.scenes.add": "Add Scene",
|
||||||
|
"playlists.scenes_count": "scenes",
|
||||||
|
"playlists.scene_one": "scene",
|
||||||
|
"playlists.scene_many": "scenes",
|
||||||
|
"playlists.items.empty": "No scenes yet — add some below",
|
||||||
|
"playlists.items.search_placeholder": "Search scenes...",
|
||||||
|
"playlists.item.duration": "Seconds",
|
||||||
|
"playlists.item.move_up": "Move up",
|
||||||
|
"playlists.item.move_down": "Move down",
|
||||||
|
"playlists.item.missing": "Missing",
|
||||||
|
"playlists.chip.loop": "Loop",
|
||||||
|
"playlists.chip.shuffle": "Shuffle",
|
||||||
|
"playlists.action.start": "Start",
|
||||||
|
"playlists.action.stop": "Stop",
|
||||||
|
"playlists.start": "Start playlist",
|
||||||
|
"playlists.stop": "Stop playlist",
|
||||||
|
"playlists.status.playing": "Playing",
|
||||||
|
"playlists.status.stopped": "Stopped",
|
||||||
|
"playlists.started": "Playlist started",
|
||||||
|
"playlists.stopped": "Playlist stopped",
|
||||||
|
"playlists.created": "Playlist created",
|
||||||
|
"playlists.updated": "Playlist updated",
|
||||||
|
"playlists.deleted": "Playlist deleted",
|
||||||
|
"playlists.delete_confirm": "Delete playlist \"{name}\"?",
|
||||||
|
"playlists.error.name_required": "Name is required",
|
||||||
|
"playlists.error.save_failed": "Failed to save playlist",
|
||||||
|
"playlists.error.start_failed": "Failed to start playlist",
|
||||||
|
"playlists.error.stop_failed": "Failed to stop playlist",
|
||||||
|
"playlists.error.delete_failed": "Failed to delete playlist",
|
||||||
|
"playlists.error.no_presets": "Create a scene preset first",
|
||||||
"dashboard.type.led": "LED",
|
"dashboard.type.led": "LED",
|
||||||
"dashboard.type.kc": "Key Colors",
|
"dashboard.type.kc": "Key Colors",
|
||||||
"aria.close": "Close",
|
"aria.close": "Close",
|
||||||
@@ -2079,8 +2153,14 @@
|
|||||||
"targets.adaptive_fps.hint": "Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.",
|
"targets.adaptive_fps.hint": "Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.",
|
||||||
"targets.protocol": "Protocol:",
|
"targets.protocol": "Protocol:",
|
||||||
"targets.protocol.hint": "DDP sends pixels via fast UDP (recommended for most setups). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.",
|
"targets.protocol.hint": "DDP sends pixels via fast UDP (recommended for most setups). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.",
|
||||||
|
"targets.power_limit": "Max current (ABL):",
|
||||||
|
"targets.power_limit.hint": "Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.",
|
||||||
|
"targets.power_limit.ma_suffix": "mA (0 = unlimited)",
|
||||||
|
"targets.power_limit.per_led": "mA per LED (full white):",
|
||||||
"targets.protocol.ddp": "DDP (UDP)",
|
"targets.protocol.ddp": "DDP (UDP)",
|
||||||
"targets.protocol.ddp.desc": "Fast raw UDP packets — recommended",
|
"targets.protocol.ddp.desc": "Fast raw UDP packets — recommended",
|
||||||
|
"targets.protocol.udp": "WLED UDP (realtime)",
|
||||||
|
"targets.protocol.udp.desc": "WLED native realtime — RGBW whites + auto-revert if the stream drops",
|
||||||
"targets.protocol.http": "HTTP",
|
"targets.protocol.http": "HTTP",
|
||||||
"targets.protocol.http.desc": "JSON API — slower, ≤500 LEDs",
|
"targets.protocol.http.desc": "JSON API — slower, ≤500 LEDs",
|
||||||
"targets.protocol.serial": "Serial",
|
"targets.protocol.serial": "Serial",
|
||||||
@@ -2524,9 +2604,9 @@
|
|||||||
"automations.rule.home_assistant.state": "State:",
|
"automations.rule.home_assistant.state": "State:",
|
||||||
"automations.rule.home_assistant.match_mode": "Match Mode:",
|
"automations.rule.home_assistant.match_mode": "Match Mode:",
|
||||||
"automations.rule.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
|
"automations.rule.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
|
||||||
"automations.rule.ha.match_mode.exact.desc": "State must match exactly",
|
"automations.rule.ha.match_mode.exact.desc": "State must match exactly",
|
||||||
"automations.rule.ha.match_mode.contains.desc": "State must contain the text",
|
"automations.rule.ha.match_mode.contains.desc": "State must contain the text",
|
||||||
"automations.rule.ha.match_mode.regex.desc": "State must match the regex pattern",
|
"automations.rule.ha.match_mode.regex.desc": "State must match the regex pattern",
|
||||||
"color_strip.clock": "Sync Clock:",
|
"color_strip.clock": "Sync Clock:",
|
||||||
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
|
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
|
||||||
"graph.title": "Graph",
|
"graph.title": "Graph",
|
||||||
@@ -2667,6 +2747,7 @@
|
|||||||
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
|
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
|
||||||
"section.empty.automations": "No automations yet. Click + to add one.",
|
"section.empty.automations": "No automations yet. Click + to add one.",
|
||||||
"section.empty.scenes": "No scene presets yet. Click + to add one.",
|
"section.empty.scenes": "No scene presets yet. Click + to add one.",
|
||||||
|
"section.empty.playlists": "No playlists yet. Click + to add one.",
|
||||||
"bulk.select": "Select",
|
"bulk.select": "Select",
|
||||||
"bulk.cancel": "Cancel",
|
"bulk.cancel": "Cancel",
|
||||||
"bulk.selected_count.one": "{count} selected",
|
"bulk.selected_count.one": "{count} selected",
|
||||||
@@ -2877,7 +2958,6 @@
|
|||||||
"donation.about_donate": "Support development",
|
"donation.about_donate": "Support development",
|
||||||
"donation.about_license": "MIT License",
|
"donation.about_license": "MIT License",
|
||||||
"donation.about_author": "Created by",
|
"donation.about_author": "Created by",
|
||||||
|
|
||||||
"streams.group.game": "Game Integration",
|
"streams.group.game": "Game Integration",
|
||||||
"tree.group.game": "Game",
|
"tree.group.game": "Game",
|
||||||
"game_integration.section_title": "Game Integrations",
|
"game_integration.section_title": "Game Integrations",
|
||||||
@@ -2936,7 +3016,6 @@
|
|||||||
"game_integration.auto_setup.game_not_found": "Game installation not found",
|
"game_integration.auto_setup.game_not_found": "Game installation not found",
|
||||||
"game_integration.auto_setup.token_generated": "Auth token was automatically generated",
|
"game_integration.auto_setup.token_generated": "Auth token was automatically generated",
|
||||||
"game_integration.auto_setup.save_first": "Save the integration first before running auto setup",
|
"game_integration.auto_setup.save_first": "Save the integration first before running auto setup",
|
||||||
|
|
||||||
"color_strip.type.game_event": "Game Event",
|
"color_strip.type.game_event": "Game Event",
|
||||||
"color_strip.type.game_event.desc": "LED effects triggered by game events",
|
"color_strip.type.game_event.desc": "LED effects triggered by game events",
|
||||||
"color_strip.game_event.integration": "Game Integration:",
|
"color_strip.game_event.integration": "Game Integration:",
|
||||||
@@ -2946,7 +3025,6 @@
|
|||||||
"color_strip.game_event.event_mappings": "Event Mappings:",
|
"color_strip.game_event.event_mappings": "Event Mappings:",
|
||||||
"color_strip.game_event.event_mappings.hint": "Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.",
|
"color_strip.game_event.event_mappings.hint": "Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.",
|
||||||
"color_strip.game_event.error.no_integration": "Please select a game integration.",
|
"color_strip.game_event.error.no_integration": "Please select a game integration.",
|
||||||
|
|
||||||
"color_strip.type.math_wave": "Math Wave",
|
"color_strip.type.math_wave": "Math Wave",
|
||||||
"color_strip.type.math_wave.desc": "Mathematical wave generator with gradient color mapping",
|
"color_strip.type.math_wave.desc": "Mathematical wave generator with gradient color mapping",
|
||||||
"color_strip.math_wave.gradient": "Color Gradient:",
|
"color_strip.math_wave.gradient": "Color Gradient:",
|
||||||
@@ -2966,7 +3044,6 @@
|
|||||||
"color_strip.math_wave.phase": "Phase",
|
"color_strip.math_wave.phase": "Phase",
|
||||||
"color_strip.math_wave.offset": "Offset",
|
"color_strip.math_wave.offset": "Offset",
|
||||||
"color_strip.math_wave.error.no_waves": "Add at least one wave layer.",
|
"color_strip.math_wave.error.no_waves": "Add at least one wave layer.",
|
||||||
|
|
||||||
"value_source.type.game_event": "Game Event",
|
"value_source.type.game_event": "Game Event",
|
||||||
"value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values",
|
"value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values",
|
||||||
"value_source.game_event.integration": "Game Integration:",
|
"value_source.game_event.integration": "Game Integration:",
|
||||||
@@ -2983,7 +3060,6 @@
|
|||||||
"value_source.game_event.default_value.hint": "Output value when no events received within timeout.",
|
"value_source.game_event.default_value.hint": "Output value when no events received within timeout.",
|
||||||
"value_source.game_event.timeout": "Timeout (s):",
|
"value_source.game_event.timeout": "Timeout (s):",
|
||||||
"value_source.game_event.timeout.hint": "Seconds of silence before reverting to the default value.",
|
"value_source.game_event.timeout.hint": "Seconds of silence before reverting to the default value.",
|
||||||
|
|
||||||
"audio_processing.title": "Audio Processing Templates",
|
"audio_processing.title": "Audio Processing Templates",
|
||||||
"audio_processing.add": "Add Audio Processing Template",
|
"audio_processing.add": "Add Audio Processing Template",
|
||||||
"audio_processing.edit": "Edit Audio Processing Template",
|
"audio_processing.edit": "Edit Audio Processing Template",
|
||||||
@@ -3135,5 +3211,108 @@
|
|||||||
"automations.rule.http_poll.operator.lt": "Less than",
|
"automations.rule.http_poll.operator.lt": "Less than",
|
||||||
"automations.rule.http_poll.operator.lt.desc": "Numeric comparison (<) — requires numeric output.",
|
"automations.rule.http_poll.operator.lt.desc": "Numeric comparison (<) — requires numeric output.",
|
||||||
"automations.rule.http_poll.operator.exists": "Exists",
|
"automations.rule.http_poll.operator.exists": "Exists",
|
||||||
"automations.rule.http_poll.operator.exists.desc": "Activates whenever a value is successfully extracted (ignores the value)."
|
"automations.rule.http_poll.operator.exists.desc": "Activates whenever a value is successfully extracted (ignores the value).",
|
||||||
|
"autocal.modal.title": "Auto-Calibrate Strip",
|
||||||
|
"autocal.trigger.label": "Auto-calibrate",
|
||||||
|
"autocal.trigger.hint": "Automatically detect LED positions by walking the strip",
|
||||||
|
"autocal.device.title": "Select Device",
|
||||||
|
"autocal.device.desc": "Choose the WLED/device that drives this LED strip. The strip will briefly light up during calibration.",
|
||||||
|
"autocal.device.label": "Device",
|
||||||
|
"autocal.error.no_device": "Please select a device to continue.",
|
||||||
|
"autocal.corner.title": "Start Corner",
|
||||||
|
"autocal.corner.desc": "Which corner is LED #0 (the very first LED of the strip)?",
|
||||||
|
"autocal.corner.led_index": "LED 0 position",
|
||||||
|
"autocal.direction.title": "Strip Direction — Step {step}",
|
||||||
|
"autocal.direction.desc": "Which direction does the strip run from the start corner?",
|
||||||
|
"autocal.corners.title": "Mark Corners — {remaining} remaining",
|
||||||
|
"autocal.corners.desc": "Sweep to the next corner then tap Mark. Corner: {corner}",
|
||||||
|
"autocal.corners.desc_complete": "All 4 corners marked! Review and continue.",
|
||||||
|
"autocal.corners.index_label": "LED index",
|
||||||
|
"autocal.preview.title": "Preview & Save",
|
||||||
|
"autocal.preview.desc": "Review the detected layout and save to the strip source.",
|
||||||
|
"autocal.preview.start": "Start corner",
|
||||||
|
"autocal.preview.top": "Top LEDs",
|
||||||
|
"autocal.preview.right": "Right LEDs",
|
||||||
|
"autocal.preview.bottom": "Bottom LEDs",
|
||||||
|
"autocal.preview.left": "Left LEDs",
|
||||||
|
"autocal.preview.total": "Total LEDs",
|
||||||
|
"autocal.position.top_left": "Top-left",
|
||||||
|
"autocal.position.top_right": "Top-right",
|
||||||
|
"autocal.position.bottom_left": "Bottom-left",
|
||||||
|
"autocal.position.bottom_right": "Bottom-right",
|
||||||
|
"autocal.btn.cancel": "Cancel",
|
||||||
|
"autocal.btn.next": "Next",
|
||||||
|
"autocal.btn.back": "Back",
|
||||||
|
"autocal.btn.step_back": "Step back",
|
||||||
|
"autocal.btn.step_fwd": "Step forward",
|
||||||
|
"autocal.btn.mark_corner": "Mark corner",
|
||||||
|
"autocal.btn.solve": "Solve",
|
||||||
|
"autocal.btn.save": "Save",
|
||||||
|
"autocal.error.session_start_failed": "Failed to start calibration session.",
|
||||||
|
"autocal.error.session_stop_failed": "Failed to stop calibration session.",
|
||||||
|
"autocal.error.position_failed": "Failed to move to LED position.",
|
||||||
|
"autocal.error.solve_failed": "Failed to solve calibration.",
|
||||||
|
"autocal.error.save_failed": "Failed to save calibration.",
|
||||||
|
"autocal.error.css_required": "Auto-calibration requires a Color Strip Source (not a device-only target).",
|
||||||
|
"autocal.saved": "Calibration saved successfully.",
|
||||||
|
"wizard.modal.title": "Setup Wizard",
|
||||||
|
"wizard.rerun": "Rerun Setup Wizard",
|
||||||
|
"wizard.skip": "Skip",
|
||||||
|
"wizard.start": "Get Started",
|
||||||
|
"wizard.step.welcome": "Welcome",
|
||||||
|
"wizard.step.device": "Device",
|
||||||
|
"wizard.step.display": "Screen",
|
||||||
|
"wizard.step.scaffold": "Setup",
|
||||||
|
"wizard.step.calibrate": "Calibrate",
|
||||||
|
"wizard.step.start": "Start",
|
||||||
|
"wizard.step.done": "Done",
|
||||||
|
"wizard.welcome.title": "Welcome to LED Grab",
|
||||||
|
"wizard.welcome.desc": "Let's get your LED strip up and running in just a few steps.",
|
||||||
|
"wizard.welcome.item1": "Connect your LED controller",
|
||||||
|
"wizard.welcome.item2": "Choose your screen to capture",
|
||||||
|
"wizard.welcome.item3": "Calibrate your strip layout",
|
||||||
|
"wizard.welcome.item4": "Start the ambient light output",
|
||||||
|
"wizard.device.title": "Find Your Device",
|
||||||
|
"wizard.device.desc": "Scan the network for compatible LED controllers, or add one manually.",
|
||||||
|
"wizard.device.scanning": "Scanning network…",
|
||||||
|
"wizard.device.discovered": "Discovered on network",
|
||||||
|
"wizard.device.none_found": "No devices found. Try adding one manually.",
|
||||||
|
"wizard.device.rescan": "Rescan",
|
||||||
|
"wizard.device.existing": "Existing devices",
|
||||||
|
"wizard.device.manual.title": "Add Manually",
|
||||||
|
"wizard.device.manual.name": "Device Name",
|
||||||
|
"wizard.device.manual.name_placeholder": "My LED Strip",
|
||||||
|
"wizard.device.manual.url": "Device URL",
|
||||||
|
"wizard.device.manual.led_count": "LED Count",
|
||||||
|
"wizard.device.manual.add": "Add Device",
|
||||||
|
"wizard.display.title": "Choose Your Screen",
|
||||||
|
"wizard.display.desc": "Select the monitor or display you want to capture for ambient lighting.",
|
||||||
|
"wizard.display.loading": "Loading displays…",
|
||||||
|
"wizard.display.no_displays": "No displays detected. Enter the display index manually.",
|
||||||
|
"wizard.display.manual_index": "Display Index",
|
||||||
|
"wizard.display.primary": "Primary",
|
||||||
|
"wizard.display.index_prefix": "Display",
|
||||||
|
"wizard.display.confirm": "Use This Screen",
|
||||||
|
"wizard.scaffold.title": "Building Setup",
|
||||||
|
"wizard.scaffold.desc": "Creating the capture chain: screen source → color strip → LED output.",
|
||||||
|
"wizard.scaffold.building": "Creating entities…",
|
||||||
|
"wizard.scaffold.done": "Setup complete! Ready to calibrate.",
|
||||||
|
"wizard.calibrate.title": "Calibrate Strip Layout",
|
||||||
|
"wizard.calibrate.desc": "Tell LedGrab where your LED strip starts and how it runs around the screen.",
|
||||||
|
"wizard.calibrate.skip": "Skip Calibration",
|
||||||
|
"wizard.start.title": "Starting Output",
|
||||||
|
"wizard.start.starting": "Starting LED output…",
|
||||||
|
"wizard.start.done": "LED output is running!",
|
||||||
|
"wizard.start.failed": "Failed to start output. You can start it manually from the Targets tab.",
|
||||||
|
"wizard.done.title": "All Done!",
|
||||||
|
"wizard.done.desc": "Your ambient LED setup is active. Enjoy the light!",
|
||||||
|
"wizard.done.device": "Device",
|
||||||
|
"wizard.done.display": "Screen",
|
||||||
|
"wizard.done.finish": "Finish",
|
||||||
|
"wizard.error.no_device": "Please select or add a device first.",
|
||||||
|
"wizard.error.device_create_failed": "Failed to create device.",
|
||||||
|
"wizard.error.device_name_required": "Device name is required.",
|
||||||
|
"wizard.error.device_url_required": "Device URL is required.",
|
||||||
|
"wizard.error.scaffold_failed": "Setup failed. Please try again.",
|
||||||
|
"wizard.error.start_failed": "Failed to start LED output."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -695,7 +695,23 @@
|
|||||||
"calibration.skip_end": "Пропуск LED (конец):",
|
"calibration.skip_end": "Пропуск LED (конец):",
|
||||||
"calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)",
|
"calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)",
|
||||||
"calibration.border_width": "Граница (px):",
|
"calibration.border_width": "Граница (px):",
|
||||||
|
"calibration.roi": "Область захвата (%):",
|
||||||
|
"calibration.roi.hint": "Брать пиксели только из этого прямоугольника экрана, чтобы панель задач, интерфейс игры или чёрные полосы не искажали цвета краёв. Весь экран = X/Y 0, Ширина/Высота 100.",
|
||||||
|
"calibration.roi.x": "X (%)",
|
||||||
|
"calibration.roi.y": "Y (%)",
|
||||||
|
"calibration.roi.width": "Ширина (%)",
|
||||||
|
"calibration.roi.height": "Высота (%)",
|
||||||
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
||||||
|
"calibration.edge.top": "Переключить тестовые светодиоды верхнего края",
|
||||||
|
"calibration.edge.right": "Переключить тестовые светодиоды правого края",
|
||||||
|
"calibration.edge.bottom": "Переключить тестовые светодиоды нижнего края",
|
||||||
|
"calibration.edge.left": "Переключить тестовые светодиоды левого края",
|
||||||
|
"calibration.corner.top_left": "Задать начальную позицию: верхний левый угол",
|
||||||
|
"calibration.corner.top_right": "Задать начальную позицию: верхний правый угол",
|
||||||
|
"calibration.corner.bottom_right": "Задать начальную позицию: нижний правый угол",
|
||||||
|
"calibration.corner.bottom_left": "Задать начальную позицию: нижний левый угол",
|
||||||
|
"calibration.direction.toggle": "Переключить направление",
|
||||||
|
"calibration.edge_inputs.toggle": "Переключить поля ввода светодиодов по краям",
|
||||||
"calibration.button.cancel": "Отмена",
|
"calibration.button.cancel": "Отмена",
|
||||||
"calibration.button.save": "Сохранить",
|
"calibration.button.save": "Сохранить",
|
||||||
"calibration.saved": "Калибровка сохранена",
|
"calibration.saved": "Калибровка сохранена",
|
||||||
@@ -725,6 +741,7 @@
|
|||||||
"common.none_own_speed": "Нет (своя скорость)",
|
"common.none_own_speed": "Нет (своя скорость)",
|
||||||
"common.undo": "Отменить",
|
"common.undo": "Отменить",
|
||||||
"common.cancel": "Отмена",
|
"common.cancel": "Отмена",
|
||||||
|
"common.back": "Назад",
|
||||||
"common.apply": "Применить",
|
"common.apply": "Применить",
|
||||||
"common.start": "ПУСК",
|
"common.start": "ПУСК",
|
||||||
"common.stop": "СТОП",
|
"common.stop": "СТОП",
|
||||||
@@ -841,6 +858,7 @@
|
|||||||
"device.icon.entity.ha_source": "Источник Home Assistant",
|
"device.icon.entity.ha_source": "Источник Home Assistant",
|
||||||
"device.icon.entity.automation": "Автоматизация",
|
"device.icon.entity.automation": "Автоматизация",
|
||||||
"device.icon.entity.scene_preset": "Сцена",
|
"device.icon.entity.scene_preset": "Сцена",
|
||||||
|
"device.icon.entity.scene_playlist": "Плейлист",
|
||||||
"device.icon.entity.sync_clock": "Часы синхронизации",
|
"device.icon.entity.sync_clock": "Часы синхронизации",
|
||||||
"device.icon.entity.game_integration": "Игровая интеграция",
|
"device.icon.entity.game_integration": "Игровая интеграция",
|
||||||
"device.icon.entity.audio_processing_template": "Шаблон обработки аудио",
|
"device.icon.entity.audio_processing_template": "Шаблон обработки аудио",
|
||||||
@@ -1138,6 +1156,7 @@
|
|||||||
"dashboard.failed": "Не удалось загрузить обзор",
|
"dashboard.failed": "Не удалось загрузить обзор",
|
||||||
"dashboard.section.automations": "Автоматизации",
|
"dashboard.section.automations": "Автоматизации",
|
||||||
"dashboard.section.scenes": "Пресеты сцен",
|
"dashboard.section.scenes": "Пресеты сцен",
|
||||||
|
"dashboard.section.playlists": "Плейлисты",
|
||||||
"dashboard.section.sync_clocks": "Синхронные часы",
|
"dashboard.section.sync_clocks": "Синхронные часы",
|
||||||
"dashboard.targets": "Цели",
|
"dashboard.targets": "Цели",
|
||||||
"dashboard.section.performance": "Производительность системы",
|
"dashboard.section.performance": "Производительность системы",
|
||||||
@@ -1269,6 +1288,17 @@
|
|||||||
"automations.rule.time_of_day.start_time": "Время начала:",
|
"automations.rule.time_of_day.start_time": "Время начала:",
|
||||||
"automations.rule.time_of_day.end_time": "Время окончания:",
|
"automations.rule.time_of_day.end_time": "Время окончания:",
|
||||||
"automations.rule.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.",
|
"automations.rule.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.",
|
||||||
|
"automations.rule.time_of_day.days": "Активные дни",
|
||||||
|
"automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.",
|
||||||
|
"automations.rule.time_of_day.timezone": "Часовой пояс",
|
||||||
|
"automations.rule.time_of_day.timezone.placeholder": "Локальное время сервера (напр. Europe/Berlin)",
|
||||||
|
"weekday.short.0": "Пн",
|
||||||
|
"weekday.short.1": "Вт",
|
||||||
|
"weekday.short.2": "Ср",
|
||||||
|
"weekday.short.3": "Чт",
|
||||||
|
"weekday.short.4": "Пт",
|
||||||
|
"weekday.short.5": "Сб",
|
||||||
|
"weekday.short.6": "Вс",
|
||||||
"automations.rule.system_idle": "Бездействие системы",
|
"automations.rule.system_idle": "Бездействие системы",
|
||||||
"automations.rule.system_idle.desc": "Бездействие/активность",
|
"automations.rule.system_idle.desc": "Бездействие/активность",
|
||||||
"automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
|
"automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
|
||||||
@@ -1368,6 +1398,50 @@
|
|||||||
"scenes.error.delete_failed": "Не удалось удалить сцену",
|
"scenes.error.delete_failed": "Не удалось удалить сцену",
|
||||||
"scenes.cloned": "Сцена клонирована",
|
"scenes.cloned": "Сцена клонирована",
|
||||||
"scenes.error.clone_failed": "Не удалось клонировать сцену",
|
"scenes.error.clone_failed": "Не удалось клонировать сцену",
|
||||||
|
"playlists.title": "Плейлисты",
|
||||||
|
"playlists.add": "Новый плейлист",
|
||||||
|
"playlists.edit": "Изменить плейлист",
|
||||||
|
"playlists.name": "Название:",
|
||||||
|
"playlists.name.placeholder": "Мой плейлист",
|
||||||
|
"playlists.description": "Описание:",
|
||||||
|
"playlists.description.hint": "Необязательное описание плейлиста",
|
||||||
|
"playlists.section.playback": "Воспроизведение",
|
||||||
|
"playlists.loop": "Зацикливание:",
|
||||||
|
"playlists.loop.hint": "Начинать заново с первой сцены после последней; если выключено — проиграть один раз и остановиться",
|
||||||
|
"playlists.shuffle": "Перемешивание:",
|
||||||
|
"playlists.shuffle.hint": "Случайный порядок сцен в начале каждого цикла",
|
||||||
|
"playlists.scenes": "Сцены:",
|
||||||
|
"playlists.scenes.hint": "Пресеты сцен, которые перебирает плейлист, каждая удерживается своё время",
|
||||||
|
"playlists.scenes.add": "Добавить сцену",
|
||||||
|
"playlists.scenes_count": "сцен",
|
||||||
|
"playlists.scene_one": "сцена",
|
||||||
|
"playlists.scene_many": "сцен",
|
||||||
|
"playlists.items.empty": "Сцен пока нет — добавьте ниже",
|
||||||
|
"playlists.items.search_placeholder": "Поиск сцен...",
|
||||||
|
"playlists.item.duration": "Секунды",
|
||||||
|
"playlists.item.move_up": "Вверх",
|
||||||
|
"playlists.item.move_down": "Вниз",
|
||||||
|
"playlists.item.missing": "Отсутствует",
|
||||||
|
"playlists.chip.loop": "Цикл",
|
||||||
|
"playlists.chip.shuffle": "Перемешать",
|
||||||
|
"playlists.action.start": "Запустить",
|
||||||
|
"playlists.action.stop": "Остановить",
|
||||||
|
"playlists.start": "Запустить плейлист",
|
||||||
|
"playlists.stop": "Остановить плейлист",
|
||||||
|
"playlists.status.playing": "Воспроизводится",
|
||||||
|
"playlists.status.stopped": "Остановлен",
|
||||||
|
"playlists.started": "Плейлист запущен",
|
||||||
|
"playlists.stopped": "Плейлист остановлен",
|
||||||
|
"playlists.created": "Плейлист создан",
|
||||||
|
"playlists.updated": "Плейлист обновлён",
|
||||||
|
"playlists.deleted": "Плейлист удалён",
|
||||||
|
"playlists.delete_confirm": "Удалить плейлист «{name}»?",
|
||||||
|
"playlists.error.name_required": "Требуется название",
|
||||||
|
"playlists.error.save_failed": "Не удалось сохранить плейлист",
|
||||||
|
"playlists.error.start_failed": "Не удалось запустить плейлист",
|
||||||
|
"playlists.error.stop_failed": "Не удалось остановить плейлист",
|
||||||
|
"playlists.error.delete_failed": "Не удалось удалить плейлист",
|
||||||
|
"playlists.error.no_presets": "Сначала создайте пресет сцены",
|
||||||
"dashboard.type.led": "LED",
|
"dashboard.type.led": "LED",
|
||||||
"dashboard.type.kc": "Цвета клавиш",
|
"dashboard.type.kc": "Цвета клавиш",
|
||||||
"aria.close": "Закрыть",
|
"aria.close": "Закрыть",
|
||||||
@@ -1939,8 +2013,14 @@
|
|||||||
"targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.",
|
"targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.",
|
||||||
"targets.protocol": "Протокол:",
|
"targets.protocol": "Протокол:",
|
||||||
"targets.protocol.hint": "DDP отправляет пиксели по быстрому UDP (рекомендуется). HTTP использует JSON API — медленнее, но надёжнее, ограничение ~500 LED.",
|
"targets.protocol.hint": "DDP отправляет пиксели по быстрому UDP (рекомендуется). HTTP использует JSON API — медленнее, но надёжнее, ограничение ~500 LED.",
|
||||||
|
"targets.power_limit": "Макс. ток (ABL):",
|
||||||
|
"targets.power_limit.hint": "Ограничивает расчётный ток ленты бюджетом блока питания, чтобы избежать просадок напряжения (сдвиг цвета, мерцание, перезагрузки) на ярких/белых сценах. Укажите номинальный ток вашего БП с запасом. 0 = без ограничения.",
|
||||||
|
"targets.power_limit.ma_suffix": "мА (0 = без ограничения)",
|
||||||
|
"targets.power_limit.per_led": "мА на светодиод (полный белый):",
|
||||||
"targets.protocol.ddp": "DDP (UDP)",
|
"targets.protocol.ddp": "DDP (UDP)",
|
||||||
"targets.protocol.ddp.desc": "Быстрые UDP-пакеты — рекомендуется",
|
"targets.protocol.ddp.desc": "Быстрые UDP-пакеты — рекомендуется",
|
||||||
|
"targets.protocol.udp": "WLED UDP (realtime)",
|
||||||
|
"targets.protocol.udp.desc": "Нативный realtime WLED — корректный RGBW и авто-возврат при обрыве потока",
|
||||||
"targets.protocol.http": "HTTP",
|
"targets.protocol.http": "HTTP",
|
||||||
"targets.protocol.http.desc": "JSON API — медленнее, ≤500 LED",
|
"targets.protocol.http.desc": "JSON API — медленнее, ≤500 LED",
|
||||||
"targets.protocol.serial": "Serial",
|
"targets.protocol.serial": "Serial",
|
||||||
@@ -2348,6 +2428,7 @@
|
|||||||
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
|
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
|
||||||
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.",
|
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.",
|
||||||
"section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.",
|
"section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.",
|
||||||
|
"section.empty.playlists": "Плейлистов пока нет. Нажмите + для добавления.",
|
||||||
"bulk.select": "Выбрать",
|
"bulk.select": "Выбрать",
|
||||||
"bulk.cancel": "Отмена",
|
"bulk.cancel": "Отмена",
|
||||||
"bulk.selected_count.one": "{count} выбран",
|
"bulk.selected_count.one": "{count} выбран",
|
||||||
@@ -2559,7 +2640,6 @@
|
|||||||
"donation.about_donate": "Поддержать разработку",
|
"donation.about_donate": "Поддержать разработку",
|
||||||
"donation.about_license": "Лицензия MIT",
|
"donation.about_license": "Лицензия MIT",
|
||||||
"donation.about_author": "Создатель —",
|
"donation.about_author": "Создатель —",
|
||||||
|
|
||||||
"streams.group.game": "Игровая интеграция",
|
"streams.group.game": "Игровая интеграция",
|
||||||
"tree.group.game": "Игры",
|
"tree.group.game": "Игры",
|
||||||
"game_integration.section_title": "Игровые интеграции",
|
"game_integration.section_title": "Игровые интеграции",
|
||||||
@@ -2618,7 +2698,6 @@
|
|||||||
"game_integration.auto_setup.game_not_found": "Установка игры не найдена",
|
"game_integration.auto_setup.game_not_found": "Установка игры не найдена",
|
||||||
"game_integration.auto_setup.token_generated": "Токен авторизации был сгенерирован автоматически",
|
"game_integration.auto_setup.token_generated": "Токен авторизации был сгенерирован автоматически",
|
||||||
"game_integration.auto_setup.save_first": "Сначала сохраните интеграцию перед запуском автонастройки",
|
"game_integration.auto_setup.save_first": "Сначала сохраните интеграцию перед запуском автонастройки",
|
||||||
|
|
||||||
"color_strip.type.game_event": "Игровое событие",
|
"color_strip.type.game_event": "Игровое событие",
|
||||||
"color_strip.type.game_event.desc": "LED-эффекты по игровым событиям",
|
"color_strip.type.game_event.desc": "LED-эффекты по игровым событиям",
|
||||||
"color_strip.game_event.integration": "Игровая интеграция:",
|
"color_strip.game_event.integration": "Игровая интеграция:",
|
||||||
@@ -2628,7 +2707,6 @@
|
|||||||
"color_strip.game_event.event_mappings": "Привязка событий:",
|
"color_strip.game_event.event_mappings": "Привязка событий:",
|
||||||
"color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.",
|
"color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.",
|
||||||
"color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.",
|
"color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.",
|
||||||
|
|
||||||
"color_strip.type.math_wave": "Математическая волна",
|
"color_strip.type.math_wave": "Математическая волна",
|
||||||
"color_strip.type.math_wave.desc": "Генератор математических волн с цветовым градиентом",
|
"color_strip.type.math_wave.desc": "Генератор математических волн с цветовым градиентом",
|
||||||
"color_strip.math_wave.gradient": "Цветовой градиент:",
|
"color_strip.math_wave.gradient": "Цветовой градиент:",
|
||||||
@@ -2648,7 +2726,6 @@
|
|||||||
"color_strip.math_wave.phase": "Фаза",
|
"color_strip.math_wave.phase": "Фаза",
|
||||||
"color_strip.math_wave.offset": "Смещение",
|
"color_strip.math_wave.offset": "Смещение",
|
||||||
"color_strip.math_wave.error.no_waves": "Добавьте хотя бы один слой волны.",
|
"color_strip.math_wave.error.no_waves": "Добавьте хотя бы один слой волны.",
|
||||||
|
|
||||||
"value_source.type.game_event": "Игровое событие",
|
"value_source.type.game_event": "Игровое событие",
|
||||||
"value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1",
|
"value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1",
|
||||||
"value_source.game_event.integration": "Игровая интеграция:",
|
"value_source.game_event.integration": "Игровая интеграция:",
|
||||||
@@ -2665,7 +2742,6 @@
|
|||||||
"value_source.game_event.default_value.hint": "Выходное значение, когда события не поступают в пределах таймаута.",
|
"value_source.game_event.default_value.hint": "Выходное значение, когда события не поступают в пределах таймаута.",
|
||||||
"value_source.game_event.timeout": "Таймаут (с):",
|
"value_source.game_event.timeout": "Таймаут (с):",
|
||||||
"value_source.game_event.timeout.hint": "Секунды тишины до возврата к значению по умолчанию.",
|
"value_source.game_event.timeout.hint": "Секунды тишины до возврата к значению по умолчанию.",
|
||||||
|
|
||||||
"audio_processing.title": "Шаблоны обработки звука",
|
"audio_processing.title": "Шаблоны обработки звука",
|
||||||
"audio_processing.add": "Добавить шаблон обработки звука",
|
"audio_processing.add": "Добавить шаблон обработки звука",
|
||||||
"audio_processing.edit": "Редактировать шаблон обработки звука",
|
"audio_processing.edit": "Редактировать шаблон обработки звука",
|
||||||
@@ -2817,5 +2893,108 @@
|
|||||||
"automations.rule.http_poll.operator.lt": "Меньше",
|
"automations.rule.http_poll.operator.lt": "Меньше",
|
||||||
"automations.rule.http_poll.operator.lt.desc": "Числовое сравнение (<) — нужно числовое значение.",
|
"automations.rule.http_poll.operator.lt.desc": "Числовое сравнение (<) — нужно числовое значение.",
|
||||||
"automations.rule.http_poll.operator.exists": "Существует",
|
"automations.rule.http_poll.operator.exists": "Существует",
|
||||||
"automations.rule.http_poll.operator.exists.desc": "Срабатывает, когда значение успешно извлечено (само значение игнорируется)."
|
"automations.rule.http_poll.operator.exists.desc": "Срабатывает, когда значение успешно извлечено (само значение игнорируется).",
|
||||||
|
"autocal.modal.title": "Авто-калибровка полосы",
|
||||||
|
"autocal.trigger.label": "Авто-калибровка",
|
||||||
|
"autocal.trigger.hint": "Автоматически определить позиции светодиодов путём обхода полосы",
|
||||||
|
"autocal.device.title": "Выбор устройства",
|
||||||
|
"autocal.device.desc": "Выберите устройство WLED, управляющее этой LED-полосой. Во время калибровки полоса ненадолго загорится.",
|
||||||
|
"autocal.device.label": "Устройство",
|
||||||
|
"autocal.error.no_device": "Пожалуйста, выберите устройство для продолжения.",
|
||||||
|
"autocal.corner.title": "Начальный угол",
|
||||||
|
"autocal.corner.desc": "В каком углу находится светодиод №0 (самый первый светодиод полосы)?",
|
||||||
|
"autocal.corner.led_index": "Позиция LED 0",
|
||||||
|
"autocal.direction.title": "Направление полосы — шаг {step}",
|
||||||
|
"autocal.direction.desc": "В каком направлении идёт полоса от начального угла?",
|
||||||
|
"autocal.corners.title": "Отметьте углы — осталось {remaining}",
|
||||||
|
"autocal.corners.desc": "Переместитесь к следующему углу и нажмите «Отметить». Угол: {corner}",
|
||||||
|
"autocal.corners.desc_complete": "Все 4 угла отмечены! Проверьте и продолжите.",
|
||||||
|
"autocal.corners.index_label": "Индекс LED",
|
||||||
|
"autocal.preview.title": "Предпросмотр и сохранение",
|
||||||
|
"autocal.preview.desc": "Проверьте обнаруженную раскладку и сохраните в источник полосы.",
|
||||||
|
"autocal.preview.start": "Начальный угол",
|
||||||
|
"autocal.preview.top": "Верхних LED",
|
||||||
|
"autocal.preview.right": "Правых LED",
|
||||||
|
"autocal.preview.bottom": "Нижних LED",
|
||||||
|
"autocal.preview.left": "Левых LED",
|
||||||
|
"autocal.preview.total": "Всего LED",
|
||||||
|
"autocal.position.top_left": "Верхний левый",
|
||||||
|
"autocal.position.top_right": "Верхний правый",
|
||||||
|
"autocal.position.bottom_left": "Нижний левый",
|
||||||
|
"autocal.position.bottom_right": "Нижний правый",
|
||||||
|
"autocal.btn.cancel": "Отмена",
|
||||||
|
"autocal.btn.next": "Далее",
|
||||||
|
"autocal.btn.back": "Назад",
|
||||||
|
"autocal.btn.step_back": "Шаг назад",
|
||||||
|
"autocal.btn.step_fwd": "Шаг вперёд",
|
||||||
|
"autocal.btn.mark_corner": "Отметить угол",
|
||||||
|
"autocal.btn.solve": "Вычислить",
|
||||||
|
"autocal.btn.save": "Сохранить",
|
||||||
|
"autocal.error.session_start_failed": "Не удалось начать сеанс калибровки.",
|
||||||
|
"autocal.error.session_stop_failed": "Не удалось завершить сеанс калибровки.",
|
||||||
|
"autocal.error.position_failed": "Не удалось переместиться к позиции LED.",
|
||||||
|
"autocal.error.solve_failed": "Не удалось вычислить калибровку.",
|
||||||
|
"autocal.error.save_failed": "Не удалось сохранить калибровку.",
|
||||||
|
"autocal.error.css_required": "Авто-калибровка требует источника цветовой полосы (не только устройства).",
|
||||||
|
"autocal.saved": "Калибровка успешно сохранена.",
|
||||||
|
"wizard.modal.title": "Мастер настройки",
|
||||||
|
"wizard.rerun": "Запустить мастер настройки заново",
|
||||||
|
"wizard.skip": "Пропустить",
|
||||||
|
"wizard.start": "Начать",
|
||||||
|
"wizard.step.welcome": "Добро пожаловать",
|
||||||
|
"wizard.step.device": "Устройство",
|
||||||
|
"wizard.step.display": "Экран",
|
||||||
|
"wizard.step.scaffold": "Настройка",
|
||||||
|
"wizard.step.calibrate": "Калибровка",
|
||||||
|
"wizard.step.start": "Запуск",
|
||||||
|
"wizard.step.done": "Готово",
|
||||||
|
"wizard.welcome.title": "Добро пожаловать в LED Grab",
|
||||||
|
"wizard.welcome.desc": "Настроим вашу LED-ленту за несколько шагов.",
|
||||||
|
"wizard.welcome.item1": "Подключите контроллер LED",
|
||||||
|
"wizard.welcome.item2": "Выберите экран для захвата",
|
||||||
|
"wizard.welcome.item3": "Откалибруйте расположение ленты",
|
||||||
|
"wizard.welcome.item4": "Запустите подсветку",
|
||||||
|
"wizard.device.title": "Найдите устройство",
|
||||||
|
"wizard.device.desc": "Выполните сканирование сети или добавьте устройство вручную.",
|
||||||
|
"wizard.device.scanning": "Сканирование сети…",
|
||||||
|
"wizard.device.discovered": "Найдено в сети",
|
||||||
|
"wizard.device.none_found": "Устройства не найдены. Попробуйте добавить вручную.",
|
||||||
|
"wizard.device.rescan": "Повторить",
|
||||||
|
"wizard.device.existing": "Существующие устройства",
|
||||||
|
"wizard.device.manual.title": "Добавить вручную",
|
||||||
|
"wizard.device.manual.name": "Имя устройства",
|
||||||
|
"wizard.device.manual.name_placeholder": "Моя LED-лента",
|
||||||
|
"wizard.device.manual.url": "Адрес устройства",
|
||||||
|
"wizard.device.manual.led_count": "Количество светодиодов",
|
||||||
|
"wizard.device.manual.add": "Добавить устройство",
|
||||||
|
"wizard.display.title": "Выберите экран",
|
||||||
|
"wizard.display.desc": "Укажите монитор для захвата подсветки.",
|
||||||
|
"wizard.display.loading": "Загрузка дисплеев…",
|
||||||
|
"wizard.display.no_displays": "Дисплеи не найдены. Введите индекс вручную.",
|
||||||
|
"wizard.display.manual_index": "Индекс дисплея",
|
||||||
|
"wizard.display.primary": "Основной",
|
||||||
|
"wizard.display.index_prefix": "Дисплей",
|
||||||
|
"wizard.display.confirm": "Использовать этот экран",
|
||||||
|
"wizard.scaffold.title": "Создание конфигурации",
|
||||||
|
"wizard.scaffold.desc": "Создаём цепочку захвата: экран → цветовая лента → LED-выход.",
|
||||||
|
"wizard.scaffold.building": "Создание объектов…",
|
||||||
|
"wizard.scaffold.done": "Конфигурация создана! Готово к калибровке.",
|
||||||
|
"wizard.calibrate.title": "Калибровка ленты",
|
||||||
|
"wizard.calibrate.desc": "Укажите, где начинается лента и как она проходит вокруг экрана.",
|
||||||
|
"wizard.calibrate.skip": "Пропустить калибровку",
|
||||||
|
"wizard.start.title": "Запуск вывода",
|
||||||
|
"wizard.start.starting": "Запуск LED-вывода…",
|
||||||
|
"wizard.start.done": "LED-вывод работает!",
|
||||||
|
"wizard.start.failed": "Не удалось запустить. Запустите вручную на вкладке «Цели».",
|
||||||
|
"wizard.done.title": "Готово!",
|
||||||
|
"wizard.done.desc": "Ваша подсветка активна. Наслаждайтесь!",
|
||||||
|
"wizard.done.device": "Устройство",
|
||||||
|
"wizard.done.display": "Экран",
|
||||||
|
"wizard.done.finish": "Завершить",
|
||||||
|
"wizard.error.no_device": "Сначала выберите или добавьте устройство.",
|
||||||
|
"wizard.error.device_create_failed": "Не удалось создать устройство.",
|
||||||
|
"wizard.error.device_name_required": "Введите имя устройства.",
|
||||||
|
"wizard.error.device_url_required": "Введите адрес устройства.",
|
||||||
|
"wizard.error.scaffold_failed": "Ошибка настройки. Попробуйте ещё раз.",
|
||||||
|
"wizard.error.start_failed": "Не удалось запустить LED-вывод."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -691,7 +691,23 @@
|
|||||||
"calibration.skip_end": "跳过 LED(末尾):",
|
"calibration.skip_end": "跳过 LED(末尾):",
|
||||||
"calibration.skip_end.hint": "灯带末尾端关闭的 LED 数量(0 = 无)",
|
"calibration.skip_end.hint": "灯带末尾端关闭的 LED 数量(0 = 无)",
|
||||||
"calibration.border_width": "边框(像素):",
|
"calibration.border_width": "边框(像素):",
|
||||||
|
"calibration.roi": "采集区域(%):",
|
||||||
|
"calibration.roi.hint": "仅采集屏幕的此子区域,避免任务栏、游戏 HUD 或黑边干扰边缘颜色。全屏 = X/Y 为 0,宽/高为 100。",
|
||||||
|
"calibration.roi.x": "X (%)",
|
||||||
|
"calibration.roi.y": "Y (%)",
|
||||||
|
"calibration.roi.width": "宽度 (%)",
|
||||||
|
"calibration.roi.height": "高度 (%)",
|
||||||
"calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)",
|
"calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)",
|
||||||
|
"calibration.edge.top": "切换顶部边缘测试 LED",
|
||||||
|
"calibration.edge.right": "切换右侧边缘测试 LED",
|
||||||
|
"calibration.edge.bottom": "切换底部边缘测试 LED",
|
||||||
|
"calibration.edge.left": "切换左侧边缘测试 LED",
|
||||||
|
"calibration.corner.top_left": "设置起始位置:左上角",
|
||||||
|
"calibration.corner.top_right": "设置起始位置:右上角",
|
||||||
|
"calibration.corner.bottom_right": "设置起始位置:右下角",
|
||||||
|
"calibration.corner.bottom_left": "设置起始位置:左下角",
|
||||||
|
"calibration.direction.toggle": "切换方向",
|
||||||
|
"calibration.edge_inputs.toggle": "切换边缘 LED 输入",
|
||||||
"calibration.button.cancel": "取消",
|
"calibration.button.cancel": "取消",
|
||||||
"calibration.button.save": "保存",
|
"calibration.button.save": "保存",
|
||||||
"calibration.saved": "校准已保存",
|
"calibration.saved": "校准已保存",
|
||||||
@@ -721,6 +737,7 @@
|
|||||||
"common.none_own_speed": "无(使用自身速度)",
|
"common.none_own_speed": "无(使用自身速度)",
|
||||||
"common.undo": "撤销",
|
"common.undo": "撤销",
|
||||||
"common.cancel": "取消",
|
"common.cancel": "取消",
|
||||||
|
"common.back": "返回",
|
||||||
"common.apply": "应用",
|
"common.apply": "应用",
|
||||||
"common.start": "启动",
|
"common.start": "启动",
|
||||||
"common.stop": "停止",
|
"common.stop": "停止",
|
||||||
@@ -837,6 +854,7 @@
|
|||||||
"device.icon.entity.ha_source": "Home Assistant 源",
|
"device.icon.entity.ha_source": "Home Assistant 源",
|
||||||
"device.icon.entity.automation": "自动化",
|
"device.icon.entity.automation": "自动化",
|
||||||
"device.icon.entity.scene_preset": "场景预设",
|
"device.icon.entity.scene_preset": "场景预设",
|
||||||
|
"device.icon.entity.scene_playlist": "播放列表",
|
||||||
"device.icon.entity.sync_clock": "同步时钟",
|
"device.icon.entity.sync_clock": "同步时钟",
|
||||||
"device.icon.entity.game_integration": "游戏集成",
|
"device.icon.entity.game_integration": "游戏集成",
|
||||||
"device.icon.entity.audio_processing_template": "音频处理模板",
|
"device.icon.entity.audio_processing_template": "音频处理模板",
|
||||||
@@ -1134,6 +1152,7 @@
|
|||||||
"dashboard.failed": "加载仪表盘失败",
|
"dashboard.failed": "加载仪表盘失败",
|
||||||
"dashboard.section.automations": "自动化",
|
"dashboard.section.automations": "自动化",
|
||||||
"dashboard.section.scenes": "场景预设",
|
"dashboard.section.scenes": "场景预设",
|
||||||
|
"dashboard.section.playlists": "播放列表",
|
||||||
"dashboard.section.sync_clocks": "同步时钟",
|
"dashboard.section.sync_clocks": "同步时钟",
|
||||||
"dashboard.targets": "目标",
|
"dashboard.targets": "目标",
|
||||||
"dashboard.section.performance": "系统性能",
|
"dashboard.section.performance": "系统性能",
|
||||||
@@ -1265,6 +1284,17 @@
|
|||||||
"automations.rule.time_of_day.start_time": "开始时间:",
|
"automations.rule.time_of_day.start_time": "开始时间:",
|
||||||
"automations.rule.time_of_day.end_time": "结束时间:",
|
"automations.rule.time_of_day.end_time": "结束时间:",
|
||||||
"automations.rule.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。",
|
"automations.rule.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。",
|
||||||
|
"automations.rule.time_of_day.days": "生效日期",
|
||||||
|
"automations.rule.time_of_day.days_hint": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。",
|
||||||
|
"automations.rule.time_of_day.timezone": "时区",
|
||||||
|
"automations.rule.time_of_day.timezone.placeholder": "服务器本地时间(如 Europe/Berlin)",
|
||||||
|
"weekday.short.0": "周一",
|
||||||
|
"weekday.short.1": "周二",
|
||||||
|
"weekday.short.2": "周三",
|
||||||
|
"weekday.short.3": "周四",
|
||||||
|
"weekday.short.4": "周五",
|
||||||
|
"weekday.short.5": "周六",
|
||||||
|
"weekday.short.6": "周日",
|
||||||
"automations.rule.system_idle": "系统空闲",
|
"automations.rule.system_idle": "系统空闲",
|
||||||
"automations.rule.system_idle.desc": "空闲/活跃",
|
"automations.rule.system_idle.desc": "空闲/活跃",
|
||||||
"automations.rule.system_idle.idle_minutes": "空闲超时(分钟):",
|
"automations.rule.system_idle.idle_minutes": "空闲超时(分钟):",
|
||||||
@@ -1364,6 +1394,50 @@
|
|||||||
"scenes.error.delete_failed": "删除场景失败",
|
"scenes.error.delete_failed": "删除场景失败",
|
||||||
"scenes.cloned": "场景已克隆",
|
"scenes.cloned": "场景已克隆",
|
||||||
"scenes.error.clone_failed": "克隆场景失败",
|
"scenes.error.clone_failed": "克隆场景失败",
|
||||||
|
"playlists.title": "播放列表",
|
||||||
|
"playlists.add": "新建播放列表",
|
||||||
|
"playlists.edit": "编辑播放列表",
|
||||||
|
"playlists.name": "名称:",
|
||||||
|
"playlists.name.placeholder": "我的播放列表",
|
||||||
|
"playlists.description": "描述:",
|
||||||
|
"playlists.description.hint": "此播放列表的可选描述",
|
||||||
|
"playlists.section.playback": "播放",
|
||||||
|
"playlists.loop": "循环:",
|
||||||
|
"playlists.loop.hint": "最后一个场景结束后从第一个重新开始;关闭则播放一遍后停止",
|
||||||
|
"playlists.shuffle": "随机:",
|
||||||
|
"playlists.shuffle.hint": "每个循环开始时随机打乱场景顺序",
|
||||||
|
"playlists.scenes": "场景:",
|
||||||
|
"playlists.scenes.hint": "此播放列表循环的场景预设,每个按各自的时长保持",
|
||||||
|
"playlists.scenes.add": "添加场景",
|
||||||
|
"playlists.scenes_count": "个场景",
|
||||||
|
"playlists.scene_one": "个场景",
|
||||||
|
"playlists.scene_many": "个场景",
|
||||||
|
"playlists.items.empty": "还没有场景 — 在下方添加",
|
||||||
|
"playlists.items.search_placeholder": "搜索场景...",
|
||||||
|
"playlists.item.duration": "秒",
|
||||||
|
"playlists.item.move_up": "上移",
|
||||||
|
"playlists.item.move_down": "下移",
|
||||||
|
"playlists.item.missing": "缺失",
|
||||||
|
"playlists.chip.loop": "循环",
|
||||||
|
"playlists.chip.shuffle": "随机",
|
||||||
|
"playlists.action.start": "开始",
|
||||||
|
"playlists.action.stop": "停止",
|
||||||
|
"playlists.start": "开始播放列表",
|
||||||
|
"playlists.stop": "停止播放列表",
|
||||||
|
"playlists.status.playing": "播放中",
|
||||||
|
"playlists.status.stopped": "已停止",
|
||||||
|
"playlists.started": "播放列表已开始",
|
||||||
|
"playlists.stopped": "播放列表已停止",
|
||||||
|
"playlists.created": "播放列表已创建",
|
||||||
|
"playlists.updated": "播放列表已更新",
|
||||||
|
"playlists.deleted": "播放列表已删除",
|
||||||
|
"playlists.delete_confirm": "删除播放列表“{name}”?",
|
||||||
|
"playlists.error.name_required": "需要名称",
|
||||||
|
"playlists.error.save_failed": "保存播放列表失败",
|
||||||
|
"playlists.error.start_failed": "启动播放列表失败",
|
||||||
|
"playlists.error.stop_failed": "停止播放列表失败",
|
||||||
|
"playlists.error.delete_failed": "删除播放列表失败",
|
||||||
|
"playlists.error.no_presets": "请先创建场景预设",
|
||||||
"dashboard.type.led": "LED",
|
"dashboard.type.led": "LED",
|
||||||
"dashboard.type.kc": "关键颜色",
|
"dashboard.type.kc": "关键颜色",
|
||||||
"aria.close": "关闭",
|
"aria.close": "关闭",
|
||||||
@@ -1935,8 +2009,14 @@
|
|||||||
"targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。",
|
"targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。",
|
||||||
"targets.protocol": "协议:",
|
"targets.protocol": "协议:",
|
||||||
"targets.protocol.hint": "DDP通过快速UDP发送像素(推荐)。HTTP使用JSON API——较慢但可靠,限制约500个LED。",
|
"targets.protocol.hint": "DDP通过快速UDP发送像素(推荐)。HTTP使用JSON API——较慢但可靠,限制约500个LED。",
|
||||||
|
"targets.power_limit": "最大电流 (ABL):",
|
||||||
|
"targets.power_limit.hint": "将灯带的估算电流限制在电源预算内,以防止明亮/白色场景下的电压骤降(颜色偏移、闪烁、重启)。请设为电源的额定电流并留有余量。0 = 不限制。",
|
||||||
|
"targets.power_limit.ma_suffix": "mA(0 = 不限制)",
|
||||||
|
"targets.power_limit.per_led": "每颗 LED 电流(全白):",
|
||||||
"targets.protocol.ddp": "DDP (UDP)",
|
"targets.protocol.ddp": "DDP (UDP)",
|
||||||
"targets.protocol.ddp.desc": "快速UDP数据包 - 推荐",
|
"targets.protocol.ddp.desc": "快速UDP数据包 - 推荐",
|
||||||
|
"targets.protocol.udp": "WLED UDP(实时)",
|
||||||
|
"targets.protocol.udp.desc": "WLED 原生实时 — 正确的 RGBW 白色,断流时自动恢复",
|
||||||
"targets.protocol.http": "HTTP",
|
"targets.protocol.http": "HTTP",
|
||||||
"targets.protocol.http.desc": "JSON API - 较慢,≤500 LED",
|
"targets.protocol.http.desc": "JSON API - 较慢,≤500 LED",
|
||||||
"targets.protocol.serial": "串口",
|
"targets.protocol.serial": "串口",
|
||||||
@@ -2344,6 +2424,7 @@
|
|||||||
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
|
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
|
||||||
"section.empty.automations": "暂无自动化。点击 + 添加。",
|
"section.empty.automations": "暂无自动化。点击 + 添加。",
|
||||||
"section.empty.scenes": "暂无场景预设。点击 + 添加。",
|
"section.empty.scenes": "暂无场景预设。点击 + 添加。",
|
||||||
|
"section.empty.playlists": "暂无播放列表。点击 + 添加。",
|
||||||
"bulk.select": "选择",
|
"bulk.select": "选择",
|
||||||
"bulk.cancel": "取消",
|
"bulk.cancel": "取消",
|
||||||
"bulk.selected_count.one": "已选 {count} 项",
|
"bulk.selected_count.one": "已选 {count} 项",
|
||||||
@@ -2553,7 +2634,6 @@
|
|||||||
"donation.about_donate": "支持开发",
|
"donation.about_donate": "支持开发",
|
||||||
"donation.about_license": "MIT 许可证",
|
"donation.about_license": "MIT 许可证",
|
||||||
"donation.about_author": "作者:",
|
"donation.about_author": "作者:",
|
||||||
|
|
||||||
"streams.group.game": "游戏集成",
|
"streams.group.game": "游戏集成",
|
||||||
"tree.group.game": "游戏",
|
"tree.group.game": "游戏",
|
||||||
"game_integration.section_title": "游戏集成",
|
"game_integration.section_title": "游戏集成",
|
||||||
@@ -2612,7 +2692,6 @@
|
|||||||
"game_integration.auto_setup.game_not_found": "未找到游戏安装",
|
"game_integration.auto_setup.game_not_found": "未找到游戏安装",
|
||||||
"game_integration.auto_setup.token_generated": "授权令牌已自动生成",
|
"game_integration.auto_setup.token_generated": "授权令牌已自动生成",
|
||||||
"game_integration.auto_setup.save_first": "请先保存集成,然后再运行自动配置",
|
"game_integration.auto_setup.save_first": "请先保存集成,然后再运行自动配置",
|
||||||
|
|
||||||
"color_strip.type.game_event": "游戏事件",
|
"color_strip.type.game_event": "游戏事件",
|
||||||
"color_strip.type.game_event.desc": "由游戏事件触发的LED效果",
|
"color_strip.type.game_event.desc": "由游戏事件触发的LED效果",
|
||||||
"color_strip.game_event.integration": "游戏集成:",
|
"color_strip.game_event.integration": "游戏集成:",
|
||||||
@@ -2622,7 +2701,6 @@
|
|||||||
"color_strip.game_event.event_mappings": "事件映射:",
|
"color_strip.game_event.event_mappings": "事件映射:",
|
||||||
"color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。",
|
"color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。",
|
||||||
"color_strip.game_event.error.no_integration": "请选择游戏集成。",
|
"color_strip.game_event.error.no_integration": "请选择游戏集成。",
|
||||||
|
|
||||||
"color_strip.type.math_wave": "数学波",
|
"color_strip.type.math_wave": "数学波",
|
||||||
"color_strip.type.math_wave.desc": "使用渐变色映射的数学波形生成器",
|
"color_strip.type.math_wave.desc": "使用渐变色映射的数学波形生成器",
|
||||||
"color_strip.math_wave.gradient": "颜色渐变:",
|
"color_strip.math_wave.gradient": "颜色渐变:",
|
||||||
@@ -2642,7 +2720,6 @@
|
|||||||
"color_strip.math_wave.phase": "相位",
|
"color_strip.math_wave.phase": "相位",
|
||||||
"color_strip.math_wave.offset": "偏移",
|
"color_strip.math_wave.offset": "偏移",
|
||||||
"color_strip.math_wave.error.no_waves": "请至少添加一个波形层。",
|
"color_strip.math_wave.error.no_waves": "请至少添加一个波形层。",
|
||||||
|
|
||||||
"value_source.type.game_event": "游戏事件",
|
"value_source.type.game_event": "游戏事件",
|
||||||
"value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值",
|
"value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值",
|
||||||
"value_source.game_event.integration": "游戏集成:",
|
"value_source.game_event.integration": "游戏集成:",
|
||||||
@@ -2659,7 +2736,6 @@
|
|||||||
"value_source.game_event.default_value.hint": "在超时时间内未收到事件时的输出值。",
|
"value_source.game_event.default_value.hint": "在超时时间内未收到事件时的输出值。",
|
||||||
"value_source.game_event.timeout": "超时(秒):",
|
"value_source.game_event.timeout": "超时(秒):",
|
||||||
"value_source.game_event.timeout.hint": "恢复到默认值前的静默秒数。",
|
"value_source.game_event.timeout.hint": "恢复到默认值前的静默秒数。",
|
||||||
|
|
||||||
"audio_processing.title": "音频处理模板",
|
"audio_processing.title": "音频处理模板",
|
||||||
"audio_processing.add": "添加音频处理模板",
|
"audio_processing.add": "添加音频处理模板",
|
||||||
"audio_processing.edit": "编辑音频处理模板",
|
"audio_processing.edit": "编辑音频处理模板",
|
||||||
@@ -2811,5 +2887,108 @@
|
|||||||
"automations.rule.http_poll.operator.lt": "小于",
|
"automations.rule.http_poll.operator.lt": "小于",
|
||||||
"automations.rule.http_poll.operator.lt.desc": "数值比较 (<) — 需要数值输出。",
|
"automations.rule.http_poll.operator.lt.desc": "数值比较 (<) — 需要数值输出。",
|
||||||
"automations.rule.http_poll.operator.exists": "存在",
|
"automations.rule.http_poll.operator.exists": "存在",
|
||||||
"automations.rule.http_poll.operator.exists.desc": "只要成功提取出值就激活(忽略值本身)。"
|
"automations.rule.http_poll.operator.exists.desc": "只要成功提取出值就激活(忽略值本身)。",
|
||||||
|
"autocal.modal.title": "自动校准灯带",
|
||||||
|
"autocal.trigger.label": "自动校准",
|
||||||
|
"autocal.trigger.hint": "通过逐一扫描灯带自动检测 LED 位置",
|
||||||
|
"autocal.device.title": "选择设备",
|
||||||
|
"autocal.device.desc": "选择驱动该 LED 灯带的 WLED 设备。校准过程中灯带会短暂亮起。",
|
||||||
|
"autocal.device.label": "设备",
|
||||||
|
"autocal.error.no_device": "请选择一个设备以继续。",
|
||||||
|
"autocal.corner.title": "起始角",
|
||||||
|
"autocal.corner.desc": "灯带第 0 颗 LED(最开始的一颗)位于哪个角?",
|
||||||
|
"autocal.corner.led_index": "LED 0 位置",
|
||||||
|
"autocal.direction.title": "灯带方向 — 步骤 {step}",
|
||||||
|
"autocal.direction.desc": "从起始角开始,灯带向哪个方向延伸?",
|
||||||
|
"autocal.corners.title": "标记角点 — 剩余 {remaining} 个",
|
||||||
|
"autocal.corners.desc": "移动到下一个角点后点击标记。当前角点:{corner}",
|
||||||
|
"autocal.corners.desc_complete": "已标记全部 4 个角点!请确认后继续。",
|
||||||
|
"autocal.corners.index_label": "LED 索引",
|
||||||
|
"autocal.preview.title": "预览并保存",
|
||||||
|
"autocal.preview.desc": "确认检测到的布局,然后保存到灯带源。",
|
||||||
|
"autocal.preview.start": "起始角",
|
||||||
|
"autocal.preview.top": "顶部 LED 数",
|
||||||
|
"autocal.preview.right": "右侧 LED 数",
|
||||||
|
"autocal.preview.bottom": "底部 LED 数",
|
||||||
|
"autocal.preview.left": "左侧 LED 数",
|
||||||
|
"autocal.preview.total": "LED 总数",
|
||||||
|
"autocal.position.top_left": "左上角",
|
||||||
|
"autocal.position.top_right": "右上角",
|
||||||
|
"autocal.position.bottom_left": "左下角",
|
||||||
|
"autocal.position.bottom_right": "右下角",
|
||||||
|
"autocal.btn.cancel": "取消",
|
||||||
|
"autocal.btn.next": "下一步",
|
||||||
|
"autocal.btn.back": "返回",
|
||||||
|
"autocal.btn.step_back": "后退一步",
|
||||||
|
"autocal.btn.step_fwd": "前进一步",
|
||||||
|
"autocal.btn.mark_corner": "标记角点",
|
||||||
|
"autocal.btn.solve": "求解",
|
||||||
|
"autocal.btn.save": "保存",
|
||||||
|
"autocal.error.session_start_failed": "无法启动校准会话。",
|
||||||
|
"autocal.error.session_stop_failed": "无法停止校准会话。",
|
||||||
|
"autocal.error.position_failed": "无法移动到 LED 位置。",
|
||||||
|
"autocal.error.solve_failed": "校准求解失败。",
|
||||||
|
"autocal.error.save_failed": "保存校准数据失败。",
|
||||||
|
"autocal.error.css_required": "自动校准需要颜色灯带源(不支持纯设备目标)。",
|
||||||
|
"autocal.saved": "校准已成功保存。",
|
||||||
|
"wizard.modal.title": "设置向导",
|
||||||
|
"wizard.rerun": "重新运行设置向导",
|
||||||
|
"wizard.skip": "跳过",
|
||||||
|
"wizard.start": "开始设置",
|
||||||
|
"wizard.step.welcome": "欢迎",
|
||||||
|
"wizard.step.device": "设备",
|
||||||
|
"wizard.step.display": "屏幕",
|
||||||
|
"wizard.step.scaffold": "配置",
|
||||||
|
"wizard.step.calibrate": "校准",
|
||||||
|
"wizard.step.start": "启动",
|
||||||
|
"wizard.step.done": "完成",
|
||||||
|
"wizard.welcome.title": "欢迎使用 LED Grab",
|
||||||
|
"wizard.welcome.desc": "只需几步,即可启动并运行您的 LED 灯带。",
|
||||||
|
"wizard.welcome.item1": "连接您的 LED 控制器",
|
||||||
|
"wizard.welcome.item2": "选择要采集的屏幕",
|
||||||
|
"wizard.welcome.item3": "校准灯带布局",
|
||||||
|
"wizard.welcome.item4": "启动氛围灯输出",
|
||||||
|
"wizard.device.title": "查找您的设备",
|
||||||
|
"wizard.device.desc": "扫描网络查找兼容的 LED 控制器,或手动添加。",
|
||||||
|
"wizard.device.scanning": "正在扫描网络…",
|
||||||
|
"wizard.device.discovered": "在网络中发现",
|
||||||
|
"wizard.device.none_found": "未找到设备。请尝试手动添加。",
|
||||||
|
"wizard.device.rescan": "重新扫描",
|
||||||
|
"wizard.device.existing": "已有设备",
|
||||||
|
"wizard.device.manual.title": "手动添加",
|
||||||
|
"wizard.device.manual.name": "设备名称",
|
||||||
|
"wizard.device.manual.name_placeholder": "我的 LED 灯带",
|
||||||
|
"wizard.device.manual.url": "设备地址",
|
||||||
|
"wizard.device.manual.led_count": "LED 数量",
|
||||||
|
"wizard.device.manual.add": "添加设备",
|
||||||
|
"wizard.display.title": "选择您的屏幕",
|
||||||
|
"wizard.display.desc": "选择用于采集氛围灯的显示器。",
|
||||||
|
"wizard.display.loading": "正在加载显示器…",
|
||||||
|
"wizard.display.no_displays": "未检测到显示器。请手动输入显示器序号。",
|
||||||
|
"wizard.display.manual_index": "显示器序号",
|
||||||
|
"wizard.display.primary": "主显示器",
|
||||||
|
"wizard.display.index_prefix": "显示器",
|
||||||
|
"wizard.display.confirm": "使用此屏幕",
|
||||||
|
"wizard.scaffold.title": "正在创建配置",
|
||||||
|
"wizard.scaffold.desc": "正在创建采集链:屏幕源 → 色带 → LED 输出。",
|
||||||
|
"wizard.scaffold.building": "正在创建实体…",
|
||||||
|
"wizard.scaffold.done": "配置完成!准备好进行校准。",
|
||||||
|
"wizard.calibrate.title": "校准灯带布局",
|
||||||
|
"wizard.calibrate.desc": "告诉 LedGrab 您的 LED 灯带从哪里开始,以及它如何绕屏幕布置。",
|
||||||
|
"wizard.calibrate.skip": "跳过校准",
|
||||||
|
"wizard.start.title": "正在启动输出",
|
||||||
|
"wizard.start.starting": "正在启动 LED 输出…",
|
||||||
|
"wizard.start.done": "LED 输出正在运行!",
|
||||||
|
"wizard.start.failed": "启动输出失败。您可以在「目标」选项卡中手动启动。",
|
||||||
|
"wizard.done.title": "全部完成!",
|
||||||
|
"wizard.done.desc": "您的氛围 LED 设置已激活。尽情享受灯光吧!",
|
||||||
|
"wizard.done.device": "设备",
|
||||||
|
"wizard.done.display": "屏幕",
|
||||||
|
"wizard.done.finish": "完成",
|
||||||
|
"wizard.error.no_device": "请先选择或添加一个设备。",
|
||||||
|
"wizard.error.device_create_failed": "创建设备失败。",
|
||||||
|
"wizard.error.device_name_required": "设备名称不能为空。",
|
||||||
|
"wizard.error.device_url_required": "设备地址不能为空。",
|
||||||
|
"wizard.error.scaffold_failed": "配置失败,请重试。",
|
||||||
|
"wizard.error.start_failed": "启动 LED 输出失败。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,27 +65,40 @@ class ApplicationRule(Rule):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TimeOfDayRule(Rule):
|
class TimeOfDayRule(Rule):
|
||||||
"""Activate during a specific time range (server local time).
|
"""Activate during a specific time range.
|
||||||
|
|
||||||
Supports overnight ranges: if start_time > end_time, the range wraps
|
Supports overnight ranges: if start_time > end_time, the range wraps
|
||||||
around midnight (e.g. 22:00 → 06:00).
|
around midnight (e.g. 22:00 → 06:00) — an overnight window belongs to the
|
||||||
|
day it *starts* on. ``days_of_week`` (0=Mon .. 6=Sun, empty = every day)
|
||||||
|
restricts which days the window is active. ``timezone`` is an IANA name
|
||||||
|
(e.g. "Europe/Berlin"); empty = the server's local time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
rule_type: str = "time_of_day"
|
rule_type: str = "time_of_day"
|
||||||
start_time: str = "00:00" # HH:MM
|
start_time: str = "00:00" # HH:MM
|
||||||
end_time: str = "23:59" # HH:MM
|
end_time: str = "23:59" # HH:MM
|
||||||
|
days_of_week: List[int] = field(default_factory=list) # 0=Mon..6=Sun; empty=all days
|
||||||
|
timezone: str = "" # IANA tz name; empty = server local time
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["start_time"] = self.start_time
|
d["start_time"] = self.start_time
|
||||||
d["end_time"] = self.end_time
|
d["end_time"] = self.end_time
|
||||||
|
d["days_of_week"] = self.days_of_week
|
||||||
|
d["timezone"] = self.timezone
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "TimeOfDayRule":
|
def from_dict(cls, data: dict) -> "TimeOfDayRule":
|
||||||
|
raw_days = data.get("days_of_week") or []
|
||||||
|
days = sorted(
|
||||||
|
{int(d) for d in raw_days if isinstance(d, (int, float)) and 0 <= int(d) <= 6}
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
start_time=data.get("start_time", "00:00"),
|
start_time=data.get("start_time", "00:00"),
|
||||||
end_time=data.get("end_time", "23:59"),
|
end_time=data.get("end_time", "23:59"),
|
||||||
|
days_of_week=days,
|
||||||
|
timezone=data.get("timezone", "") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ _ENTITY_TABLES = [
|
|||||||
"value_sources",
|
"value_sources",
|
||||||
"automations",
|
"automations",
|
||||||
"scene_presets",
|
"scene_presets",
|
||||||
|
"scene_playlists",
|
||||||
"sync_clocks",
|
"sync_clocks",
|
||||||
"color_strip_processing_templates",
|
"color_strip_processing_templates",
|
||||||
"gradients",
|
"gradients",
|
||||||
|
|||||||
@@ -10,6 +10,48 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
|
from ledgrab.utils import secret_box
|
||||||
|
|
||||||
|
# Keys inside ``adapter_config`` that hold secrets and must be encrypted at
|
||||||
|
# rest (and decrypted on load). Mirrors the secret_box pattern used by
|
||||||
|
# storage/http_endpoint.py for its ``auth_token`` field.
|
||||||
|
_SECRET_CONFIG_KEYS = ("auth_token",)
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return a copy of *adapter_config* with secret values encrypted.
|
||||||
|
|
||||||
|
``secret_box.encrypt`` is a no-op on already-encrypted envelopes, so this
|
||||||
|
is safe to call repeatedly and on configs that were never plaintext.
|
||||||
|
"""
|
||||||
|
result = dict(adapter_config)
|
||||||
|
for key in _SECRET_CONFIG_KEYS:
|
||||||
|
value = result.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
result[key] = secret_box.encrypt(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return a copy of *adapter_config* with secret values decrypted.
|
||||||
|
|
||||||
|
Migration-safe: ``secret_box.decrypt`` returns the input unchanged when it
|
||||||
|
is not an encryption envelope, so legacy plaintext rows still load. On a
|
||||||
|
corrupt/undecryptable envelope, fall back to dropping the value rather than
|
||||||
|
crashing the whole store load.
|
||||||
|
"""
|
||||||
|
result = dict(adapter_config)
|
||||||
|
for key in _SECRET_CONFIG_KEYS:
|
||||||
|
value = result.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
if secret_box.is_encrypted(value):
|
||||||
|
try:
|
||||||
|
result[key] = secret_box.decrypt(value)
|
||||||
|
except Exception:
|
||||||
|
result[key] = ""
|
||||||
|
# else: legacy plaintext — leave as-is (migration-safe read path).
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EventMapping:
|
class EventMapping:
|
||||||
@@ -89,7 +131,8 @@ class GameIntegrationConfig:
|
|||||||
"name": self.name,
|
"name": self.name,
|
||||||
"adapter_type": self.adapter_type,
|
"adapter_type": self.adapter_type,
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"adapter_config": dict(self.adapter_config),
|
# Encrypt secret values (e.g. webhook auth_token) at rest.
|
||||||
|
"adapter_config": _encrypt_adapter_config(self.adapter_config),
|
||||||
"event_mappings": [m.to_dict() for m in self.event_mappings],
|
"event_mappings": [m.to_dict() for m in self.event_mappings],
|
||||||
"created_at": self.created_at.isoformat(),
|
"created_at": self.created_at.isoformat(),
|
||||||
"updated_at": self.updated_at.isoformat(),
|
"updated_at": self.updated_at.isoformat(),
|
||||||
@@ -110,7 +153,8 @@ class GameIntegrationConfig:
|
|||||||
name=data["name"],
|
name=data["name"],
|
||||||
adapter_type=data["adapter_type"],
|
adapter_type=data["adapter_type"],
|
||||||
enabled=data.get("enabled", True),
|
enabled=data.get("enabled", True),
|
||||||
adapter_config=data.get("adapter_config", {}),
|
# Decrypt secret values; tolerant of legacy-plaintext rows.
|
||||||
|
adapter_config=_decrypt_adapter_config(data.get("adapter_config", {})),
|
||||||
event_mappings=mappings,
|
event_mappings=mappings,
|
||||||
created_at=(
|
created_at=(
|
||||||
datetime.fromisoformat(data["created_at"])
|
datetime.fromisoformat(data["created_at"])
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
min_brightness_threshold: Any = 0,
|
min_brightness_threshold: Any = 0,
|
||||||
adaptive_fps: bool = False,
|
adaptive_fps: bool = False,
|
||||||
protocol: str = "ddp",
|
protocol: str = "ddp",
|
||||||
|
max_milliamps: int = 0,
|
||||||
|
milliamps_per_led: int = 55,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
tags: List[str] | None = None,
|
tags: List[str] | None = None,
|
||||||
# legacy compat
|
# legacy compat
|
||||||
@@ -116,6 +118,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0),
|
min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0),
|
||||||
adaptive_fps=adaptive_fps,
|
adaptive_fps=adaptive_fps,
|
||||||
protocol=protocol,
|
protocol=protocol,
|
||||||
|
max_milliamps=max(0, int(max_milliamps or 0)),
|
||||||
|
milliamps_per_led=max(1, int(milliamps_per_led or 55)),
|
||||||
description=description,
|
description=description,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -335,6 +339,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
min_brightness_threshold: Any = None,
|
min_brightness_threshold: Any = None,
|
||||||
adaptive_fps: bool | None = None,
|
adaptive_fps: bool | None = None,
|
||||||
protocol: str | None = None,
|
protocol: str | None = None,
|
||||||
|
max_milliamps: int | None = None,
|
||||||
|
milliamps_per_led: int | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
tags: List[str] | None = None,
|
tags: List[str] | None = None,
|
||||||
icon: str | None = None,
|
icon: str | None = None,
|
||||||
@@ -356,6 +362,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
|||||||
min_brightness_threshold=min_brightness_threshold,
|
min_brightness_threshold=min_brightness_threshold,
|
||||||
adaptive_fps=adaptive_fps,
|
adaptive_fps=adaptive_fps,
|
||||||
protocol=protocol,
|
protocol=protocol,
|
||||||
|
max_milliamps=max_milliamps,
|
||||||
|
milliamps_per_led=milliamps_per_led,
|
||||||
description=description,
|
description=description,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
icon=icon,
|
icon=icon,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class PostprocessingTemplate:
|
|||||||
tags: List[str] = field(default_factory=list)
|
tags: List[str] = field(default_factory=list)
|
||||||
icon: str = ""
|
icon: str = ""
|
||||||
icon_color: str = ""
|
icon_color: str = ""
|
||||||
|
is_builtin: bool = False
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert template to dictionary."""
|
"""Convert template to dictionary."""
|
||||||
@@ -31,6 +32,7 @@ class PostprocessingTemplate:
|
|||||||
"updated_at": self.updated_at.isoformat(),
|
"updated_at": self.updated_at.isoformat(),
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
"tags": self.tags,
|
"tags": self.tags,
|
||||||
|
"is_builtin": self.is_builtin,
|
||||||
}
|
}
|
||||||
if self.icon:
|
if self.icon:
|
||||||
d["icon"] = self.icon
|
d["icon"] = self.icon
|
||||||
@@ -61,4 +63,5 @@ class PostprocessingTemplate:
|
|||||||
tags=data.get("tags", []),
|
tags=data.get("tags", []),
|
||||||
icon=data.get("icon", "") or "",
|
icon=data.get("icon", "") or "",
|
||||||
icon_color=data.get("icon_color", "") or "",
|
icon_color=data.get("icon_color", "") or "",
|
||||||
|
is_builtin=data.get("is_builtin", False),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,57 @@ from ledgrab.utils import get_logger
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Curated, read-only "look" presets — opinionated filter chains that give
|
||||||
|
# instant good-looking output before a user discovers the filter pipeline.
|
||||||
|
# Each entry: id-suffix -> (display name, description, [(filter_id, options), ...]).
|
||||||
|
# Only verified filters/option keys are used.
|
||||||
|
_BUILTIN_LOOKS: dict[str, tuple[str, str, list[tuple[str, dict]]]] = {
|
||||||
|
"cinematic": (
|
||||||
|
"Cinematic",
|
||||||
|
"Letterbox-aware, gently smoothed, mild colour boost — tuned for films.",
|
||||||
|
[
|
||||||
|
("auto_crop", {"threshold": 16, "min_bar_size": 20, "min_aspect_ratio": 1.4}),
|
||||||
|
("saturation", {"value": 1.12}),
|
||||||
|
("temporal_blur", {"strength": 0.35}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"vivid": (
|
||||||
|
"Vivid",
|
||||||
|
"Punchy and responsive with high saturation — tuned for games.",
|
||||||
|
[
|
||||||
|
("saturation", {"value": 1.4}),
|
||||||
|
("contrast", {"value": 1.18}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"cozy": (
|
||||||
|
"Cozy",
|
||||||
|
"Warm, dim and smooth — relaxed evening ambience.",
|
||||||
|
[
|
||||||
|
("color_correction", {"temperature": 3800}),
|
||||||
|
("brightness", {"value": 0.85}),
|
||||||
|
("saturation", {"value": 0.95}),
|
||||||
|
("temporal_blur", {"strength": 0.45}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"soft": (
|
||||||
|
"Soft",
|
||||||
|
"Heavily smoothed and calm — minimises flicker on busy content.",
|
||||||
|
[
|
||||||
|
("temporal_blur", {"strength": 0.55}),
|
||||||
|
("saturation", {"value": 0.98}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"cool": (
|
||||||
|
"Cool",
|
||||||
|
"Crisp, cool-white and clean — a modern, neutral look.",
|
||||||
|
[
|
||||||
|
("color_correction", {"temperature": 8000}),
|
||||||
|
("saturation", {"value": 1.1}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
|
class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
|
||||||
"""Storage for postprocessing templates.
|
"""Storage for postprocessing templates.
|
||||||
|
|
||||||
@@ -29,11 +80,42 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
|
|||||||
def __init__(self, db: Database):
|
def __init__(self, db: Database):
|
||||||
super().__init__(db, PostprocessingTemplate.from_dict)
|
super().__init__(db, PostprocessingTemplate.from_dict)
|
||||||
self._ensure_initial_template()
|
self._ensure_initial_template()
|
||||||
|
self._seed_missing_builtins()
|
||||||
|
|
||||||
# Backward-compatible aliases
|
# Backward-compatible aliases
|
||||||
get_all_templates = BaseSqliteStore.get_all
|
get_all_templates = BaseSqliteStore.get_all
|
||||||
get_template = BaseSqliteStore.get
|
get_template = BaseSqliteStore.get
|
||||||
delete_template = BaseSqliteStore.delete
|
|
||||||
|
def _seed_missing_builtins(self) -> None:
|
||||||
|
"""Seed any curated built-in "look" templates not yet in the store."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
added = 0
|
||||||
|
for key, (name, description, chain) in _BUILTIN_LOOKS.items():
|
||||||
|
tid = f"pp_builtin_{key}"
|
||||||
|
if tid in self._items:
|
||||||
|
continue
|
||||||
|
template = PostprocessingTemplate(
|
||||||
|
id=tid,
|
||||||
|
name=name,
|
||||||
|
filters=[FilterInstance(fid, dict(opts)) for fid, opts in chain],
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
description=description,
|
||||||
|
tags=["look"],
|
||||||
|
is_builtin=True,
|
||||||
|
)
|
||||||
|
self._items[tid] = template
|
||||||
|
self._save_item(tid, template)
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
logger.info(f"Seeded {added} new built-in look templates")
|
||||||
|
|
||||||
|
def delete_template(self, template_id: str) -> None:
|
||||||
|
"""Delete a template. Built-in looks are read-only."""
|
||||||
|
template = self.get(template_id)
|
||||||
|
if getattr(template, "is_builtin", False):
|
||||||
|
raise ValueError("Built-in look templates cannot be deleted. Clone to customise.")
|
||||||
|
self.delete(template_id)
|
||||||
|
|
||||||
def _ensure_initial_template(self) -> None:
|
def _ensure_initial_template(self) -> None:
|
||||||
"""Auto-create a default postprocessing template if none exist."""
|
"""Auto-create a default postprocessing template if none exist."""
|
||||||
@@ -114,6 +196,9 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
|
|||||||
) -> PostprocessingTemplate:
|
) -> PostprocessingTemplate:
|
||||||
template = self.get(template_id)
|
template = self.get(template_id)
|
||||||
|
|
||||||
|
if getattr(template, "is_builtin", False):
|
||||||
|
raise ValueError("Built-in look templates are read-only. Clone to customise.")
|
||||||
|
|
||||||
if name is not None:
|
if name is not None:
|
||||||
self._check_name_unique(name, exclude_id=template_id)
|
self._check_name_unique(name, exclude_id=template_id)
|
||||||
template.name = name
|
template.name = name
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Scene playlist data models — an ordered, timed sequence of scene presets.
|
||||||
|
|
||||||
|
A playlist auto-cycles through its items, activating each referenced scene
|
||||||
|
preset and holding it for the item's dwell duration before advancing. The
|
||||||
|
runtime that drives the cycling lives in
|
||||||
|
``core/scenes/playlist_engine.py``; this module only describes the persisted
|
||||||
|
shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
# Dwell-duration guard rails (seconds). A floor avoids a busy-loop when a
|
||||||
|
# user fat-fingers ``0``; the ceiling is just a sane upper bound for a UI.
|
||||||
|
MIN_DURATION_SECONDS = 1.0
|
||||||
|
MAX_DURATION_SECONDS = 86_400.0 # 24h
|
||||||
|
DEFAULT_DURATION_SECONDS = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_duration(value: float) -> float:
|
||||||
|
"""Clamp a dwell duration into ``[MIN, MAX]`` seconds.
|
||||||
|
|
||||||
|
Coerces non-numeric/None input to the default rather than raising, so a
|
||||||
|
malformed persisted value can never wedge the cycling loop.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
seconds = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return DEFAULT_DURATION_SECONDS
|
||||||
|
if seconds < MIN_DURATION_SECONDS:
|
||||||
|
return MIN_DURATION_SECONDS
|
||||||
|
if seconds > MAX_DURATION_SECONDS:
|
||||||
|
return MAX_DURATION_SECONDS
|
||||||
|
return seconds
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlaylistItem:
|
||||||
|
"""One step in a playlist: a scene preset held for ``duration_seconds``."""
|
||||||
|
|
||||||
|
scene_preset_id: str
|
||||||
|
duration_seconds: float = DEFAULT_DURATION_SECONDS
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"scene_preset_id": self.scene_preset_id,
|
||||||
|
"duration_seconds": self.duration_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "PlaylistItem":
|
||||||
|
return cls(
|
||||||
|
scene_preset_id=data["scene_preset_id"],
|
||||||
|
duration_seconds=clamp_duration(data.get("duration_seconds", DEFAULT_DURATION_SECONDS)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScenePlaylist:
|
||||||
|
"""A named, ordered sequence of scene presets that auto-cycles."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
items: List[PlaylistItem] = field(default_factory=list)
|
||||||
|
# When True, restart from the first item after the last one finishes.
|
||||||
|
# When False, the playlist stops (and leaves the last scene applied).
|
||||||
|
loop: bool = True
|
||||||
|
# When True, the item order is shuffled at the start of every cycle.
|
||||||
|
shuffle: bool = False
|
||||||
|
tags: List[str] = field(default_factory=list)
|
||||||
|
# Custom card icon (frontend display only)
|
||||||
|
icon: str = ""
|
||||||
|
icon_color: str = ""
|
||||||
|
order: int = 0
|
||||||
|
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"items": [i.to_dict() for i in self.items],
|
||||||
|
"loop": self.loop,
|
||||||
|
"shuffle": self.shuffle,
|
||||||
|
"tags": self.tags,
|
||||||
|
"order": self.order,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
if self.icon:
|
||||||
|
d["icon"] = self.icon
|
||||||
|
if self.icon_color:
|
||||||
|
d["icon_color"] = self.icon_color
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ScenePlaylist":
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description", ""),
|
||||||
|
items=[PlaylistItem.from_dict(i) for i in data.get("items", [])],
|
||||||
|
loop=data.get("loop", True),
|
||||||
|
shuffle=data.get("shuffle", False),
|
||||||
|
tags=data.get("tags", []),
|
||||||
|
icon=data.get("icon", ""),
|
||||||
|
icon_color=data.get("icon_color", ""),
|
||||||
|
order=data.get("order", 0),
|
||||||
|
created_at=datetime.fromisoformat(
|
||||||
|
data.get("created_at", datetime.now(timezone.utc).isoformat())
|
||||||
|
),
|
||||||
|
updated_at=datetime.fromisoformat(
|
||||||
|
data.get("updated_at", datetime.now(timezone.utc).isoformat())
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Scene playlist storage using SQLite."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
|
||||||
|
from ledgrab.storage.database import Database
|
||||||
|
from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistStore(BaseSqliteStore[ScenePlaylist]):
|
||||||
|
"""Persistent storage for scene playlists."""
|
||||||
|
|
||||||
|
_table_name = "scene_playlists"
|
||||||
|
_entity_name = "Scene playlist"
|
||||||
|
_cloneable = True
|
||||||
|
|
||||||
|
def __init__(self, db: Database):
|
||||||
|
super().__init__(db, ScenePlaylist.from_dict)
|
||||||
|
|
||||||
|
# Backward-compatible aliases
|
||||||
|
get_playlist = BaseSqliteStore.get
|
||||||
|
delete_playlist = BaseSqliteStore.delete
|
||||||
|
|
||||||
|
def get_all_playlists(self) -> List[ScenePlaylist]:
|
||||||
|
"""Get all playlists sorted by order field."""
|
||||||
|
return sorted(self._items.values(), key=lambda p: p.order)
|
||||||
|
|
||||||
|
# Override get_all to also sort by order for consistency
|
||||||
|
def get_all(self) -> List[ScenePlaylist]:
|
||||||
|
return self.get_all_playlists()
|
||||||
|
|
||||||
|
def create_playlist(self, playlist: ScenePlaylist) -> ScenePlaylist:
|
||||||
|
self._check_name_unique(playlist.name)
|
||||||
|
|
||||||
|
self._items[playlist.id] = playlist
|
||||||
|
self._save_item(playlist.id, playlist)
|
||||||
|
logger.info(f"Created scene playlist: {playlist.name} ({playlist.id})")
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
def update_playlist(
|
||||||
|
self,
|
||||||
|
playlist_id: str,
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
items: List[PlaylistItem] | None = None,
|
||||||
|
loop: bool | None = None,
|
||||||
|
shuffle: bool | None = None,
|
||||||
|
order: int | None = None,
|
||||||
|
tags: List[str] | None = None,
|
||||||
|
icon: str | None = None,
|
||||||
|
icon_color: str | None = None,
|
||||||
|
) -> ScenePlaylist:
|
||||||
|
playlist = self.get(playlist_id)
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
self._check_name_unique(name, exclude_id=playlist_id)
|
||||||
|
playlist.name = name
|
||||||
|
if description is not None:
|
||||||
|
playlist.description = description
|
||||||
|
if items is not None:
|
||||||
|
playlist.items = items
|
||||||
|
if loop is not None:
|
||||||
|
playlist.loop = loop
|
||||||
|
if shuffle is not None:
|
||||||
|
playlist.shuffle = shuffle
|
||||||
|
if order is not None:
|
||||||
|
playlist.order = order
|
||||||
|
if tags is not None:
|
||||||
|
playlist.tags = tags
|
||||||
|
if icon is not None:
|
||||||
|
playlist.icon = icon or ""
|
||||||
|
if icon_color is not None:
|
||||||
|
playlist.icon_color = icon_color or ""
|
||||||
|
|
||||||
|
playlist.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._save_item(playlist_id, playlist)
|
||||||
|
logger.info(f"Updated scene playlist: {playlist_id}")
|
||||||
|
return playlist
|
||||||
@@ -24,6 +24,11 @@ class WledOutputTarget(OutputTarget, type_key="led"):
|
|||||||
min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
|
min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
|
||||||
adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive
|
adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive
|
||||||
protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API)
|
protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API)
|
||||||
|
# Automatic brightness limiting (ABL): cap estimated strip draw to a PSU
|
||||||
|
# budget. max_milliamps <= 0 disables it. milliamps_per_led is the full-white
|
||||||
|
# draw of one LED (WS2812-class default 55 mA).
|
||||||
|
max_milliamps: int = 0
|
||||||
|
milliamps_per_led: int = 55
|
||||||
|
|
||||||
def register_with_manager(self, manager) -> None:
|
def register_with_manager(self, manager) -> None:
|
||||||
"""Register this WLED target with the processor manager."""
|
"""Register this WLED target with the processor manager."""
|
||||||
@@ -39,6 +44,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
|
|||||||
min_brightness_threshold=self.min_brightness_threshold,
|
min_brightness_threshold=self.min_brightness_threshold,
|
||||||
adaptive_fps=self.adaptive_fps,
|
adaptive_fps=self.adaptive_fps,
|
||||||
protocol=self.protocol,
|
protocol=self.protocol,
|
||||||
|
max_milliamps=self.max_milliamps,
|
||||||
|
milliamps_per_led=self.milliamps_per_led,
|
||||||
)
|
)
|
||||||
|
|
||||||
def sync_with_manager(
|
def sync_with_manager(
|
||||||
@@ -59,6 +66,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
|
|||||||
"state_check_interval": self.state_check_interval,
|
"state_check_interval": self.state_check_interval,
|
||||||
"min_brightness_threshold": self.min_brightness_threshold,
|
"min_brightness_threshold": self.min_brightness_threshold,
|
||||||
"adaptive_fps": self.adaptive_fps,
|
"adaptive_fps": self.adaptive_fps,
|
||||||
|
"max_milliamps": self.max_milliamps,
|
||||||
|
"milliamps_per_led": self.milliamps_per_led,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if css_changed:
|
if css_changed:
|
||||||
@@ -81,6 +90,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
|
|||||||
min_brightness_threshold=None,
|
min_brightness_threshold=None,
|
||||||
adaptive_fps=None,
|
adaptive_fps=None,
|
||||||
protocol=None,
|
protocol=None,
|
||||||
|
max_milliamps=None,
|
||||||
|
milliamps_per_led=None,
|
||||||
description=None,
|
description=None,
|
||||||
tags: List[str] | None = None,
|
tags: List[str] | None = None,
|
||||||
icon: str | None = None,
|
icon: str | None = None,
|
||||||
@@ -122,6 +133,10 @@ class WledOutputTarget(OutputTarget, type_key="led"):
|
|||||||
self.adaptive_fps = adaptive_fps
|
self.adaptive_fps = adaptive_fps
|
||||||
if protocol is not None:
|
if protocol is not None:
|
||||||
self.protocol = protocol
|
self.protocol = protocol
|
||||||
|
if max_milliamps is not None:
|
||||||
|
self.max_milliamps = max(0, int(max_milliamps))
|
||||||
|
if milliamps_per_led is not None:
|
||||||
|
self.milliamps_per_led = max(1, int(milliamps_per_led))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_picture_source(self) -> bool:
|
def has_picture_source(self) -> bool:
|
||||||
@@ -139,6 +154,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
|
|||||||
d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
|
d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
|
||||||
d["adaptive_fps"] = self.adaptive_fps
|
d["adaptive_fps"] = self.adaptive_fps
|
||||||
d["protocol"] = self.protocol
|
d["protocol"] = self.protocol
|
||||||
|
d["max_milliamps"] = self.max_milliamps
|
||||||
|
d["milliamps_per_led"] = self.milliamps_per_led
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -165,6 +182,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
|
|||||||
),
|
),
|
||||||
adaptive_fps=data.get("adaptive_fps", False),
|
adaptive_fps=data.get("adaptive_fps", False),
|
||||||
protocol=data.get("protocol", "ddp"),
|
protocol=data.get("protocol", "ddp"),
|
||||||
|
max_milliamps=int(data.get("max_milliamps", 0) or 0),
|
||||||
|
milliamps_per_led=int(data.get("milliamps_per_led", 55) or 55),
|
||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
tags=data.get("tags", []),
|
tags=data.get("tags", []),
|
||||||
icon=data.get("icon", ""),
|
icon=data.get("icon", ""),
|
||||||
|
|||||||
@@ -75,6 +75,9 @@
|
|||||||
<div class="header-toolbar">
|
<div class="header-toolbar">
|
||||||
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
|
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
|
||||||
<span class="header-toolbar-sep"></span>
|
<span class="header-toolbar-sep"></span>
|
||||||
|
<button class="header-btn" id="wizard-rerun-btn" onclick="openSetupWizard()" data-i18n-title="wizard.rerun" title="Setup Wizard">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||||
|
</button>
|
||||||
<button class="header-btn" id="tour-restart-btn" onclick="startGettingStartedTutorial()" data-i18n-title="tour.restart" title="Restart tutorial">
|
<button class="header-btn" id="tour-restart-btn" onclick="startGettingStartedTutorial()" data-i18n-title="tour.restart" title="Restart tutorial">
|
||||||
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -222,8 +225,10 @@
|
|||||||
|
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
|
||||||
|
{% include 'modals/setup-wizard.html' %}
|
||||||
{% include 'modals/calibration.html' %}
|
{% include 'modals/calibration.html' %}
|
||||||
{% include 'modals/advanced-calibration.html' %}
|
{% include 'modals/advanced-calibration.html' %}
|
||||||
|
{% include 'modals/auto-calibration.html' %}
|
||||||
{% include 'modals/device-settings.html' %}
|
{% include 'modals/device-settings.html' %}
|
||||||
{% include 'modals/icon-picker.html' %}
|
{% include 'modals/icon-picker.html' %}
|
||||||
{% include 'modals/target-editor.html' %}
|
{% include 'modals/target-editor.html' %}
|
||||||
@@ -246,6 +251,7 @@
|
|||||||
{% include 'modals/cspt-modal.html' %}
|
{% include 'modals/cspt-modal.html' %}
|
||||||
{% include 'modals/automation-editor.html' %}
|
{% include 'modals/automation-editor.html' %}
|
||||||
{% include 'modals/scene-preset-editor.html' %}
|
{% include 'modals/scene-preset-editor.html' %}
|
||||||
|
{% include 'modals/scene-playlist-editor.html' %}
|
||||||
{% include 'modals/audio-source-editor.html' %}
|
{% include 'modals/audio-source-editor.html' %}
|
||||||
{% include 'modals/test-audio-source.html' %}
|
{% include 'modals/test-audio-source.html' %}
|
||||||
{% include 'modals/audio-template.html' %}
|
{% include 'modals/audio-template.html' %}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!-- Auto-Calibration Modal — guided chase-tap wizard.
|
||||||
|
Opened from the calibration modal via the "Auto-calibrate" button.
|
||||||
|
All step rendering is done by auto-calibration.ts; this shell provides
|
||||||
|
the Modal frame and a container div that the TS mounts steps into.
|
||||||
|
|
||||||
|
Channel: signal (green) — same as the calibration modal's layout section.
|
||||||
|
Max-width kept narrower than the full calibration modal (560px). -->
|
||||||
|
<div id="auto-calibration-modal" class="modal" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="autocal-modal-title">
|
||||||
|
<div class="modal-content" style="max-width:560px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="autocal-modal-title">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24">
|
||||||
|
<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/>
|
||||||
|
<path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/>
|
||||||
|
<path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/>
|
||||||
|
</svg>
|
||||||
|
<span data-i18n="autocal.modal.title">Auto-Calibrate Strip</span>
|
||||||
|
</h2>
|
||||||
|
<button class="modal-close-btn" onclick="closeAutoCalModal()"
|
||||||
|
title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 20px 24px;">
|
||||||
|
<!-- Hidden context inputs -->
|
||||||
|
<input type="hidden" id="autocal-modal-css-id">
|
||||||
|
<input type="hidden" id="autocal-modal-device-id">
|
||||||
|
|
||||||
|
<!-- Step container: auto-calibration.ts mounts here -->
|
||||||
|
<div id="autocal-step-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -56,10 +56,10 @@
|
|||||||
<div class="calibration-preview">
|
<div class="calibration-preview">
|
||||||
<!-- Screen with direction toggle, total LEDs, and offset -->
|
<!-- Screen with direction toggle, total LEDs, and offset -->
|
||||||
<div class="preview-screen">
|
<div class="preview-screen">
|
||||||
<button type="button" class="direction-toggle" onclick="toggleDirection()" title="Toggle direction">
|
<button type="button" class="direction-toggle" onclick="toggleDirection()" data-i18n-title="calibration.direction.toggle" title="Toggle direction">
|
||||||
<span id="direction-icon"><svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg></span> <span id="direction-label">CW</span>
|
<span id="direction-icon"><svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg></span> <span id="direction-label">CW</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
|
<div class="preview-screen-total" onclick="toggleEdgeInputs()" data-i18n-title="calibration.edge_inputs.toggle" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
|
||||||
<div class="preview-screen-border-width">
|
<div class="preview-screen-border-width">
|
||||||
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label>
|
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label>
|
||||||
<input type="number" id="cal-border-width" min="1" max="100" value="10">
|
<input type="number" id="cal-border-width" min="1" max="100" value="10">
|
||||||
@@ -104,16 +104,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edge test toggle zones (outside container border, in tick area) -->
|
<!-- Edge test toggle zones (outside container border, in tick area) -->
|
||||||
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')"></button>
|
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')" data-i18n-aria-label="calibration.edge.top" aria-label="Toggle top edge test LEDs"></button>
|
||||||
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')"></button>
|
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')" data-i18n-aria-label="calibration.edge.right" aria-label="Toggle right edge test LEDs"></button>
|
||||||
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')"></button>
|
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')" data-i18n-aria-label="calibration.edge.bottom" aria-label="Toggle bottom edge test LEDs"></button>
|
||||||
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')"></button>
|
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')" data-i18n-aria-label="calibration.edge.left" aria-label="Toggle left edge test LEDs"></button>
|
||||||
|
|
||||||
<!-- Corner start position buttons -->
|
<!-- Corner start position buttons -->
|
||||||
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')">●</button>
|
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')" data-i18n-aria-label="calibration.corner.top_left" aria-label="Set start position: top left">●</button>
|
||||||
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')">●</button>
|
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')" data-i18n-aria-label="calibration.corner.top_right" aria-label="Set start position: top right">●</button>
|
||||||
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')">●</button>
|
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')" data-i18n-aria-label="calibration.corner.bottom_left" aria-label="Set start position: bottom left">●</button>
|
||||||
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')">●</button>
|
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')" data-i18n-aria-label="calibration.corner.bottom_right" aria-label="Set start position: bottom right">●</button>
|
||||||
|
|
||||||
<!-- Canvas overlay for ticks, arrows, start label -->
|
<!-- Canvas overlay for ticks, arrows, start label -->
|
||||||
<canvas id="calibration-preview-canvas"></canvas>
|
<canvas id="calibration-preview-canvas"></canvas>
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
<span class="ds-section-index" aria-hidden="true">03</span>
|
<span class="ds-section-index" aria-hidden="true">03</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ds-section-body">
|
<div class="ds-section-body">
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-md);">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label>
|
<label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label>
|
||||||
@@ -170,6 +170,31 @@
|
|||||||
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: var(--space-md);">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="calibration.roi">Capture region (%):</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="calibration.roi.hint">Sample only this sub-rectangle of the screen so a taskbar, HUD, or black bars don't pollute the border colours. Full frame = X/Y 0, Width/Height 100.</small>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: var(--space-md);">
|
||||||
|
<div>
|
||||||
|
<label for="cal-roi-x" class="time-range-label" data-i18n="calibration.roi.x">X (%)</label>
|
||||||
|
<input type="number" id="cal-roi-x" min="0" max="100" value="0">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="cal-roi-y" class="time-range-label" data-i18n="calibration.roi.y">Y (%)</label>
|
||||||
|
<input type="number" id="cal-roi-y" min="0" max="100" value="0">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="cal-roi-width" class="time-range-label" data-i18n="calibration.roi.width">Width (%)</label>
|
||||||
|
<input type="number" id="cal-roi-width" min="1" max="100" value="100">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="cal-roi-height" class="time-range-label" data-i18n="calibration.roi.height">Height (%)</label>
|
||||||
|
<input type="number" id="cal-roi-height" min="1" max="100" value="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -208,6 +233,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-icon btn-secondary" onclick="closeCalibrationModal()" title="Cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
<button class="btn btn-icon btn-secondary" onclick="closeCalibrationModal()" title="Cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||||
|
<button class="btn btn-secondary btn-sm autocal-trigger-btn" id="calibration-auto-cal-btn"
|
||||||
|
onclick="openAutoCalFromCalibration()"
|
||||||
|
data-i18n-title="autocal.trigger.hint"
|
||||||
|
title="Auto-calibrate">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m4.93 4.93 14.14 14.14"/><path d="M12 8v4l2 2"/></svg>
|
||||||
|
<span data-i18n="autocal.trigger.label">Auto-calibrate</span>
|
||||||
|
</button>
|
||||||
<button class="btn btn-icon btn-primary" onclick="saveCalibration()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
<button class="btn btn-icon btn-primary" onclick="saveCalibration()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<!-- Scene Playlist Editor Modal — ordered, timed sequence of scene presets.
|
||||||
|
Mirrors the scene-preset / automation editor rack-panel vocabulary:
|
||||||
|
.ds-section wrappers carry the channel accent (signal = identity,
|
||||||
|
amber = playback, cyan = scenes). Inner element IDs are consumed by
|
||||||
|
scene-playlists.ts. -->
|
||||||
|
<div id="playlist-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="playlist-editor-title">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="playlist-editor-title"><svg class="icon" viewBox="0 0 24 24"><path d="M21 15V6"/><path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5"/><path d="M12 12H3"/><path d="M16 6H3"/><path d="M12 18H3"/></svg> <span data-i18n="playlists.add">New Playlist</span></h2>
|
||||||
|
<button class="modal-close-btn" onclick="closePlaylistEditor()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="playlist-editor-form">
|
||||||
|
<input type="hidden" id="playlist-editor-id">
|
||||||
|
|
||||||
|
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
|
||||||
|
<section class="ds-section" data-ds-key="identity" data-ch="signal">
|
||||||
|
<div class="ds-section-header">
|
||||||
|
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||||
|
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
|
||||||
|
<span class="ds-section-index" aria-hidden="true">01</span>
|
||||||
|
</div>
|
||||||
|
<div class="ds-section-body">
|
||||||
|
<div class="form-group ds-name-group">
|
||||||
|
<label for="playlist-editor-name" data-i18n="playlists.name">Name:</label>
|
||||||
|
<input type="text" id="playlist-editor-name" data-i18n-placeholder="playlists.name.placeholder" placeholder="My Playlist" required>
|
||||||
|
<div id="playlist-tags-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="playlist-editor-description" data-i18n="playlists.description">Description:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="playlists.description.hint">Optional description of what this playlist does</small>
|
||||||
|
<input type="text" id="playlist-editor-description">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 02 · PLAYBACK ───────────────────────────────── -->
|
||||||
|
<section class="ds-section" data-ds-key="playback" data-ch="amber">
|
||||||
|
<div class="ds-section-header">
|
||||||
|
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||||
|
<span class="ds-section-title" data-i18n="playlists.section.playback">Playback</span>
|
||||||
|
<span class="ds-section-index" aria-hidden="true">02</span>
|
||||||
|
</div>
|
||||||
|
<div class="ds-section-body">
|
||||||
|
<div class="ds-toggle-row">
|
||||||
|
<div class="ds-toggle-text">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="playlist-editor-loop" data-i18n="playlists.loop">Loop:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="playlists.loop.hint">Restart from the first scene after the last one; off plays through once and stops</small>
|
||||||
|
</div>
|
||||||
|
<label class="settings-toggle">
|
||||||
|
<input type="checkbox" id="playlist-editor-loop" checked>
|
||||||
|
<span class="settings-toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ds-toggle-row">
|
||||||
|
<div class="ds-toggle-text">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="playlist-editor-shuffle" data-i18n="playlists.shuffle">Shuffle:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="playlists.shuffle.hint">Randomise the scene order at the start of every cycle</small>
|
||||||
|
</div>
|
||||||
|
<label class="settings-toggle">
|
||||||
|
<input type="checkbox" id="playlist-editor-shuffle">
|
||||||
|
<span class="settings-toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 03 · SCENES ─────────────────────────────────── -->
|
||||||
|
<section class="ds-section" data-ds-key="scenes" data-ch="cyan">
|
||||||
|
<div class="ds-section-header">
|
||||||
|
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||||
|
<span class="ds-section-title" data-i18n="playlists.scenes">Scenes</span>
|
||||||
|
<span class="ds-section-index" aria-hidden="true">03</span>
|
||||||
|
</div>
|
||||||
|
<div class="ds-section-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="playlists.scenes">Scenes:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="playlists.scenes.hint">The scene presets this playlist cycles through, each held for its own duration</small>
|
||||||
|
<div id="playlist-item-list" class="playlist-item-list" data-empty="No scenes yet"></div>
|
||||||
|
<button type="button" id="playlist-item-add-btn" class="scene-target-add-slot" onclick="addPlaylistItem()"><span data-i18n="playlists.scenes.add">Add Scene</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="playlist-editor-error" class="error-message" style="display: none;"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="closePlaylistEditor()" title="Cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="savePlaylist()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!-- Setup Wizard Modal — first-run guided setup.
|
||||||
|
Opened automatically on first visit (app.ts checks onboarding flag)
|
||||||
|
and can be reopened via the toolbar wizard button.
|
||||||
|
|
||||||
|
Channel: accent (green) — same as the main calibration modal.
|
||||||
|
All step rendering is handled by setup-wizard.ts. -->
|
||||||
|
<div id="setup-wizard-modal" class="modal" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="wizard-modal-title">
|
||||||
|
<div class="modal-content" style="max-width:600px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="wizard-modal-title">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24">
|
||||||
|
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
|
||||||
|
<path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/>
|
||||||
|
</svg>
|
||||||
|
<span data-i18n="wizard.modal.title">Setup Wizard</span>
|
||||||
|
</h2>
|
||||||
|
<button class="modal-close-btn" onclick="wizardSkip()"
|
||||||
|
title="Skip" data-i18n-aria-label="aria.close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 20px 24px;">
|
||||||
|
<!-- Progress bar and pip indicators -->
|
||||||
|
<div id="wizard-progress-bar" class="wizard-progress-bar"></div>
|
||||||
|
<div id="wizard-progress-labels" class="wizard-progress-labels"></div>
|
||||||
|
|
||||||
|
<!-- Step container: setup-wizard.ts mounts here -->
|
||||||
|
<div id="wizard-step-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -123,6 +123,7 @@
|
|||||||
<small class="input-hint" style="display:none" data-i18n="targets.protocol.hint">DDP sends pixels via fast UDP (recommended). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.</small>
|
<small class="input-hint" style="display:none" data-i18n="targets.protocol.hint">DDP sends pixels via fast UDP (recommended). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.</small>
|
||||||
<select id="target-editor-protocol">
|
<select id="target-editor-protocol">
|
||||||
<option value="ddp">DDP (UDP)</option>
|
<option value="ddp">DDP (UDP)</option>
|
||||||
|
<option value="udp">WLED UDP (realtime)</option>
|
||||||
<option value="http">HTTP</option>
|
<option value="http">HTTP</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,6 +139,22 @@
|
|||||||
<small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
|
<small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
|
||||||
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
|
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="target-editor-power-limit-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="target-editor-max-milliamps" data-i18n="targets.power_limit">Max current (ABL):</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="targets.power_limit.hint">Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.</small>
|
||||||
|
<div class="label-row">
|
||||||
|
<input type="number" id="target-editor-max-milliamps" min="0" max="200000" step="100" value="0">
|
||||||
|
<span data-i18n="targets.power_limit.ma_suffix">mA (0 = unlimited)</span>
|
||||||
|
</div>
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="target-editor-ma-per-led" data-i18n="targets.power_limit.per_led">mA per LED (full white):</label>
|
||||||
|
<input type="number" id="target-editor-ma-per-led" min="1" max="200" step="1" value="55">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""Happy-path and bounds-validation tests for calibration API routes.
|
||||||
|
|
||||||
|
Runs with the full app test-client stack but mocks the ProcessorManager
|
||||||
|
so no real LED devices are required.
|
||||||
|
|
||||||
|
Note: Deep adversarial coverage is deferred to the Phase 4 test-writer
|
||||||
|
(Big Bang strategy).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
from ledgrab.api.routes.calibration import router
|
||||||
|
from ledgrab.core.capture.calibration_session import get_calibration_session
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def mock_manager() -> MagicMock:
|
||||||
|
"""A minimal fake ProcessorManager."""
|
||||||
|
mgr = MagicMock()
|
||||||
|
# Simulate a registered device with 100 LEDs
|
||||||
|
ds = MagicMock()
|
||||||
|
ds.led_count = 100
|
||||||
|
mgr._devices = {"dev1": ds}
|
||||||
|
mgr.get_processing_target_for_device = MagicMock(return_value=None)
|
||||||
|
mgr.stop_processing = AsyncMock()
|
||||||
|
mgr.start_processing = AsyncMock()
|
||||||
|
mgr.send_clear_pixels = AsyncMock()
|
||||||
|
mgr.set_calibration_pixel = AsyncMock()
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def reset_session():
|
||||||
|
"""Reset the module-level CalibrationSession singleton before each test."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
def _clear(session) -> None:
|
||||||
|
session._active = False
|
||||||
|
session._device_id = None
|
||||||
|
session._led_count = 0
|
||||||
|
session._prior_target_id = None
|
||||||
|
session._last_activity = None
|
||||||
|
session._manager = None
|
||||||
|
# Reset lock so a test that aborted mid-await doesn't leave it locked
|
||||||
|
session._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
session = get_calibration_session()
|
||||||
|
# Cancel any leftover watchdog task before clearing
|
||||||
|
if session._timeout_task and not session._timeout_task.done():
|
||||||
|
session._timeout_task.cancel()
|
||||||
|
try:
|
||||||
|
await session._timeout_task
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
session._timeout_task = None
|
||||||
|
_clear(session)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Cleanup after test
|
||||||
|
if session._timeout_task and not session._timeout_task.done():
|
||||||
|
session._timeout_task.cancel()
|
||||||
|
try:
|
||||||
|
await session._timeout_task
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
session._timeout_task = None
|
||||||
|
_clear(session)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def app(mock_manager: MagicMock) -> FastAPI:
|
||||||
|
"""Tiny FastAPI app with only the calibration router and auth disabled."""
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from ledgrab.api.auth import verify_api_key
|
||||||
|
from ledgrab.api import dependencies as deps_mod
|
||||||
|
|
||||||
|
_app = FastAPI()
|
||||||
|
_app.include_router(router)
|
||||||
|
# Override the underlying dependency that AuthRequired resolves to
|
||||||
|
_app.dependency_overrides[verify_api_key] = lambda: "test-token"
|
||||||
|
_app.dependency_overrides[deps_mod.get_processor_manager] = lambda: mock_manager
|
||||||
|
return _app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def client(app: FastAPI):
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session start
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_session_success(client: AsyncClient, mock_manager: MagicMock):
|
||||||
|
resp = await client.post("/api/v1/calibration/session", json={"device_id": "dev1"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["active"] is True
|
||||||
|
assert data["device_id"] == "dev1"
|
||||||
|
assert data["led_count"] == 100
|
||||||
|
mock_manager.send_clear_pixels.assert_awaited_once_with("dev1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_session_unknown_device(client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/calibration/session", json={"device_id": "does_not_exist"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session position
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_position_success(client: AsyncClient, mock_manager: MagicMock):
|
||||||
|
# Start session first
|
||||||
|
await client.post("/api/v1/calibration/session", json={"device_id": "dev1"})
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/session/position", json={"index": 42, "window": 2}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["active"] is True
|
||||||
|
mock_manager.set_calibration_pixel.assert_awaited_with("dev1", 42, window=2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_position_out_of_range(client: AsyncClient, mock_manager: MagicMock):
|
||||||
|
"""index >= led_count → 400."""
|
||||||
|
await client.post("/api/v1/calibration/session", json={"device_id": "dev1"})
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/session/position", json={"index": 100, "window": 1}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_position_negative_index_422(client: AsyncClient):
|
||||||
|
"""index < 0 → Pydantic 422."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/session/position", json={"index": -1, "window": 1}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_position_no_active_session(client: AsyncClient):
|
||||||
|
"""Calling position without starting a session → 400."""
|
||||||
|
resp = await client.post("/api/v1/calibration/session/position", json={"index": 5, "window": 1})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session stop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_session_clears_device(client: AsyncClient, mock_manager: MagicMock):
|
||||||
|
await client.post("/api/v1/calibration/session", json={"device_id": "dev1"})
|
||||||
|
resp = await client.post("/api/v1/calibration/session/stop")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["active"] is False
|
||||||
|
# send_clear_pixels called at start AND at stop
|
||||||
|
assert mock_manager.send_clear_pixels.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_restores_prior_target(client: AsyncClient, mock_manager: MagicMock):
|
||||||
|
"""When a target was running, stop should restart it."""
|
||||||
|
mock_manager.get_processing_target_for_device = MagicMock(return_value="tgt1")
|
||||||
|
await client.post("/api/v1/calibration/session", json={"device_id": "dev1"})
|
||||||
|
await client.post("/api/v1/calibration/session/stop")
|
||||||
|
mock_manager.start_processing.assert_awaited_once_with("tgt1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_no_active_session_is_ok(client: AsyncClient):
|
||||||
|
"""stop when inactive → 200 with active=False."""
|
||||||
|
resp = await client.post("/api/v1/calibration/session/stop")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_state_inactive(client: AsyncClient):
|
||||||
|
resp = await client.get("/api/v1/calibration/session/state")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_state_active(client: AsyncClient, mock_manager: MagicMock):
|
||||||
|
await client.post("/api/v1/calibration/session", json={"device_id": "dev1"})
|
||||||
|
resp = await client.get("/api/v1/calibration/session/state")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["active"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Solve endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_solve_with_device_id(client: AsyncClient):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
json={
|
||||||
|
"device_id": "dev1",
|
||||||
|
"start_position": "bottom_left",
|
||||||
|
"layout": "clockwise",
|
||||||
|
"corner_indices": [0, 30, 60, 80],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["mode"] == "simple"
|
||||||
|
# bottom_left/clockwise EDGE_ORDER: left, top, right, bottom
|
||||||
|
# left=30, top=30, right=20, bottom=20 → total=100
|
||||||
|
assert data["leds_left"] == 30
|
||||||
|
assert data["leds_top"] == 30
|
||||||
|
assert data["leds_right"] == 20
|
||||||
|
assert data["leds_bottom"] == 20
|
||||||
|
assert data["layout"] == "clockwise"
|
||||||
|
assert data["start_position"] == "bottom_left"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_solve_with_led_count(client: AsyncClient):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
json={
|
||||||
|
"led_count": 80,
|
||||||
|
"start_position": "top_left",
|
||||||
|
"layout": "clockwise",
|
||||||
|
"corner_indices": [0, 20, 40, 60],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert sum([data["leds_top"], data["leds_right"], data["leds_bottom"], data["leds_left"]]) == 80
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_solve_missing_device_and_led_count(client: AsyncClient):
|
||||||
|
"""Omitting both device_id and led_count → 422 (model validator)."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
json={
|
||||||
|
"start_position": "bottom_left",
|
||||||
|
"layout": "clockwise",
|
||||||
|
"corner_indices": [0, 25, 50, 75],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_solve_unknown_device(client: AsyncClient):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
json={
|
||||||
|
"device_id": "no_such_device",
|
||||||
|
"start_position": "bottom_left",
|
||||||
|
"layout": "clockwise",
|
||||||
|
"corner_indices": [0, 25, 50, 75],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_solve_invalid_start_position_422(client: AsyncClient):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
json={
|
||||||
|
"led_count": 100,
|
||||||
|
"start_position": "invalid_corner",
|
||||||
|
"layout": "clockwise",
|
||||||
|
"corner_indices": [0, 25, 50, 75],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_solve_invalid_layout_422(client: AsyncClient):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
json={
|
||||||
|
"led_count": 100,
|
||||||
|
"start_position": "bottom_left",
|
||||||
|
"layout": "diagonal",
|
||||||
|
"corner_indices": [0, 25, 50, 75],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_solve_wrong_corner_count_422(client: AsyncClient):
|
||||||
|
"""Only 3 corner indices → 422 (min_length=4)."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
json={
|
||||||
|
"led_count": 100,
|
||||||
|
"start_position": "bottom_left",
|
||||||
|
"layout": "clockwise",
|
||||||
|
"corner_indices": [0, 25, 50],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_solve_with_offset(client: AsyncClient):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
json={
|
||||||
|
"led_count": 100,
|
||||||
|
"start_position": "bottom_left",
|
||||||
|
"layout": "clockwise",
|
||||||
|
"corner_indices": [0, 25, 50, 75],
|
||||||
|
"offset": 7,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["offset"] == 7
|
||||||
@@ -341,6 +341,64 @@ class TestEventIngestion:
|
|||||||
assert len(recent) == 1
|
assert len(recent) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Failed-auth rate limiting (brute-force defence on the ingest route)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _reset_auth_fail_limiter():
|
||||||
|
"""Clear the module-level failed-auth hit map before and after each test.
|
||||||
|
|
||||||
|
The limiter keeps per-IP state in a process-global dict, so without this
|
||||||
|
reset, attempts from earlier tests would bleed into later ones.
|
||||||
|
"""
|
||||||
|
from ledgrab.api.routes import game_integration as gi
|
||||||
|
|
||||||
|
gi._auth_fail_hits.clear()
|
||||||
|
yield
|
||||||
|
gi._auth_fail_hits.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestIngestRateLimiting:
|
||||||
|
def test_failed_auth_attempts_are_rate_limited(self, client, _reset_auth_fail_limiter):
|
||||||
|
from ledgrab.api.routes import game_integration as gi
|
||||||
|
|
||||||
|
created = _create_integration(
|
||||||
|
client,
|
||||||
|
adapter_config={"auth_token": "correct_token"},
|
||||||
|
)
|
||||||
|
integration_id = created["id"]
|
||||||
|
url = f"/api/v1/game-integrations/{integration_id}/event"
|
||||||
|
bad = {"x-auth-token": "wrong_token"}
|
||||||
|
|
||||||
|
# Burn through the failed-auth budget — each returns 403.
|
||||||
|
for _ in range(gi._AUTH_FAIL_LIMIT):
|
||||||
|
resp = client.post(url, json={"data": {"health": 1}}, headers=bad)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
# The next attempt from the same IP is throttled with 429.
|
||||||
|
resp = client.post(url, json={"data": {"health": 1}}, headers=bad)
|
||||||
|
assert resp.status_code == 429
|
||||||
|
|
||||||
|
def test_successful_ingest_not_rate_limited(self, client, event_bus, _reset_auth_fail_limiter):
|
||||||
|
from ledgrab.api.routes import game_integration as gi
|
||||||
|
|
||||||
|
created = _create_integration(
|
||||||
|
client,
|
||||||
|
adapter_config={"auth_token": "correct_token"},
|
||||||
|
)
|
||||||
|
integration_id = created["id"]
|
||||||
|
url = f"/api/v1/game-integrations/{integration_id}/event"
|
||||||
|
good = {"x-auth-token": "correct_token"}
|
||||||
|
|
||||||
|
# High-rate legitimate ingestion well past the failed-auth threshold
|
||||||
|
# must NOT be throttled — only failures count toward the limit.
|
||||||
|
for _ in range(gi._AUTH_FAIL_LIMIT + 10):
|
||||||
|
resp = client.post(url, json={"data": {"health": 50}}, headers=good)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Status / diagnostics tests
|
# Status / diagnostics tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user