Compare commits

...

18 Commits

Author SHA1 Message Date
alexei.dolgolyov dd43f3836d fix(calibration-wizard): all-provider discovery + spatial corner picker
- Setup wizard discovery now scans every device provider, not just WLED:
  drop the hardcoded device_type=wled filter so the backend's parallel
  all-provider scan runs (Adalight, DDP, OpenRGB, BLE, DMX, etc). The list
  UI was already multi-type aware (per-type icon + badge).
- Auto-calibration corner picker: finish the unfinished step. The empty
  .autocal-corner-glyph + dead --top-left/etc modifier classes now render a
  mini-screen frame with the matching corner lit, so users map the physical
  lit LED to a button at a glance. Hover/focus lights the corner dot.
- Remove dead empty CSS rule for #wizard-rerun-btn.
2026-06-08 17:59:56 +03:00
alexei.dolgolyov d32961085d Merge feature/edge-calibration-wizard: auto edge-calibration + first-run wizard
Auto edge-calibration via on-screen chase (#4) and a guided first-run setup
wizard (#3); spatial model (#11) intentionally excluded. Also adds scene
playlists + cycling state to the /api/v1/snapshot poll.

- calibration solver + chase session (lock, idle-timeout, stop/restore) + /api/v1/calibration/* (phase 1)
- POST /api/v1/setup/scaffold (rollback, registers target with manager) + onboarding flag (phase 2)
- reusable browser-driven auto-calibration flow + calibration-modal entry (phase 3)
- guided first-run wizard with first-run trigger + tour suppression (phase 4)
- snapshot endpoint returns scene_playlists + playlist_state

Full suite 2149 passed / 2 skipped; tsc clean; build passes; ruff clean.
2026-06-08 17:00:41 +03:00
alexei.dolgolyov 6cd5e057da fix(setup): register scaffolded target with ProcessorManager + final-review hardening
Final-review blocker: the setup scaffold created the LED output target in the
store but never registered it with the ProcessorManager, so the wizard's
"Start" step 404'd on a fresh setup (target not found) — the lights never
started despite a success screen. Now the scaffold calls
target.register_with_manager(manager) right after create (mirroring the
canonical POST /output-targets route, same ValueError guard), so
start_processing finds the target. Rollback unregisters via
manager.remove_target before deleting the store entity, so a post-registration
failure leaves no half-registered target.

Also from the final review:
- solve corner_indices elements now bounded ge=0 (clear 422 instead of silent
  modulo-wrap).
- setup-wizard.ts: reuse tutorials' suppressGettingStartedTour()/TOUR_KEY
  instead of a duplicated 'tour_completed' literal; drop a duplicate manual-form
  submit listener.

Tests: + adversarial pass over the whole feature (solver/session/scaffold edge
cases) and a scaffold->register->startable regression test. Full suite
2149 passed / 2 skipped; tsc clean; build passes; ruff clean.
2026-06-08 16:55:36 +03:00
alexei.dolgolyov 81b18089e1 feat(onboarding): guided first-run setup wizard (phase 4, final)
A multi-step first-run wizard that takes a brand-new user from install to a
running, calibrated ambient light in ~2 minutes, orchestrating the existing
primitives (no node graph required).

- features/setup-wizard.ts + modals/setup-wizard.html: welcome -> find device
  (discovery list + manual add via the canonical, URL-validated POST /devices)
  -> pick screen (GET /config/displays) -> scaffold (POST /setup/scaffold) ->
  calibrate (embeds the phase-3 auto-calibration flow via mountAutoCalibration
  on the scaffolded CSS + device) -> start -> done, with a progress indicator.
- First-run trigger in app.ts (checkAndOpenWizardIfNeeded): on load, if the
  onboarding flag is unset AND no output targets exist, the wizard takes over
  and the tooltip tour is suppressed; on finish/skip it PUTs the onboarding
  flag and sets localStorage tour_completed so neither re-fires. Re-runnable.
- tutorials.ts exposes TOUR_KEY + a takeover hook so the getting-started tour
  and the wizard never double-fire.
- Calibrate step always calls unmountAutoCalibration() on exit so the device
  is restored. i18n in en/ru/zh (wizard.* keys + common.back).

Final phase of the edge-calibration + first-run-wizard feature. Big Bang final
gate green: tsc --noEmit clean, npm run build passes, full pytest suite
2064 passed / 2 skipped, ruff clean.
2026-06-08 16:27:55 +03:00
alexei.dolgolyov abc204c04e feat(snapshot): include scene playlists + cycling state in snapshot
The aggregated /api/v1/snapshot poll now emits a `scene_playlists` section
(each playlist with its `is_running` flag) plus a companion `playlist_state`
key carrying the single global cycling state (running playlist, current index/
preset, dwell) — so the HA-coordinator and other low-overhead pollers get
playlist state in the same round trip as scenes/targets, matching the other
entity sections. Gated by the `scene_playlists` include-section like the rest.

Reuses the existing list_scene_playlists handler; snapshot route tests updated.
2026-06-08 16:22:47 +03:00
alexei.dolgolyov 9550688c1e feat(calibration): browser-driven auto edge-calibration UI (phase 3)
Reusable, chase-driven calibration flow that solves + saves the linear
CalibrationConfig with a few taps — no LED counting — and works in the
browser on desktop and Android (no Tkinter dependency).

- features/auto-calibration.ts: 5-step flow (start corner -> direction ->
  tap-to-mark-corners -> solved preview -> save). Drives the phase-1 session
  endpoints (session/position/solve) and persists via PUT /color-strip-
  sources/{id}. cornerIndices[0] is anchored to strip index 0 per the solver
  contract. unmountAutoCalibration() is the single cleanup gate — the
  calibration session is always stopped (device restored) on cancel, modal
  close, after save, AND on a mid-flow error, so the strip is never left dark
  or stuck.
- Public API mountAutoCalibration({container, cssId, deviceId, onComplete,
  onCancel}) for the phase-4 wizard to embed; showAutoCalibration() standalone.
- "Auto-calibrate" entry added to the existing calibration modal; standalone
  modal template; app.ts/global.d.ts exports; .autocal-* CSS matching the
  ds-section vocabulary; 43 autocal.* i18n keys in en/ru/zh; docs/CALIBRATION.md.

Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate
phase — full build/suite + test-writer gated at the final phase).
2026-06-08 15:52:45 +03:00
alexei.dolgolyov 9dcd76d264 feat(setup): one-call setup scaffold + onboarding flag (phase 2)
Backend for the first-run wizard (phase 4).

- POST /api/v1/setup/scaffold: given an existing device_id + display_index
  (+ optional calibration), wires a working chain via the real validated
  store create paths — create-or-reuse capture template -> raw picture
  source -> picture color-strip source (calibration or default) -> LED
  output target -> returns the ids. Does NOT auto-start. Rolls back every
  entity it created (reverse order) on any partial failure, leaving no
  orphans; "created" events are deferred until the whole chain succeeds so
  a rolled-back scaffold never leaves ghost cards in the UI.
- Requires an existing device_id (no inline device creation) — the wizard
  creates the device first via the canonical, URL-validated POST /devices,
  so the scaffold can't bypass device validation. display_index is bounded.
- GET/PUT /api/v1/preferences/onboarding: persistent first-run flag
  ({onboarded, completed_at}) via db.set_setting; server stamps completed_at.
- Both routes AuthRequired. Tests: 25 (scaffold happy/reuse/rollback/
  validation + onboarding + calibration round-trip integration). docs/API.md.

Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate
phase — full build/suite gated at the final phase).
2026-06-08 15:22:04 +03:00
alexei.dolgolyov 0409cd8b66 feat(calibration): auto edge-calibration backend core (phase 1)
Backend engine for guided LED-chase calibration, driven by the upcoming
auto-calibration UI (phase 3) and first-run wizard (phase 4).

- solve_calibration(): pure function mapping start corner + direction + 4
  corner-tap indices to per-edge LED counts, consistent with EDGE_ORDER/
  EDGE_REVERSE so it round-trips through build_segments().
- CalibrationChaseMixin.set_calibration_pixel(): light a specific LED index
  (+ optional window) on a device, reusing the device_test_mode idle-client
  send path.
- CalibrationSession: single-active session with start/position/stop/cancel,
  a 60s idle-timeout watchdog, and a concurrency lock so interleaved calls
  can't corrupt the stop/restore bookkeeping — start() stops + remembers any
  running target on the device and stop/cancel/timeout always restore it
  (never leaves the device dark or stuck in chase).
- Routes /api/v1/calibration/{session,session/position,session/stop,
  session/cancel,session/state,solve} (all AuthRequired, bounds-validated);
  calibration is persisted by reusing the existing PUT /color-strip-sources/
  {id} (hot-reloads running streams) rather than a duplicate endpoint.
- Tests: 19 solver pure-logic + 19 route/bounds. docs/API.md updated.

Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate
phase — full build/suite gated at the final phase).
2026-06-08 14:59:58 +03:00
alexei.dolgolyov 6180569b10 wip(dashboard): in-progress dashboard customization changes
Snapshot of uncommitted dashboard-customization work (dashboard, customize,
layout, component styles, config defaults, and en/ru/zh locales) committed
as-is to clear the working tree before branching the edge-calibration +
first-run-wizard feature. Not independently verified to build.
2026-06-08 14:33:33 +03:00
alexei.dolgolyov f71e10ee06 feat(scenes): scene playlists with timed auto-cycling
Add ordered, timed sequences of scene presets that auto-cycle — activating
each preset and holding it for its dwell duration before advancing.

Backend:
- ScenePlaylist / PlaylistItem models + SQLite store (new scene_playlists table)
- PlaylistEngine: cycles ONE playlist at a time (starting one stops any other),
  loop/shuffle, re-reads the playlist each cycle so edits/deletes apply at the
  boundary, skips missing presets, guards against busy-loops; reuses the shared
  apply_scene_state path used by scene presets and automations
- REST API: CRUD + /start, /stop, /state with scene-preset reference validation
- Constructed in the app lifespan with a bounded stop on shutdown

Frontend:
- New "Playlists" sub-tab in the Automations tab with start/stop controls and a
  running indicator; editor modal with ordered scene rows (reorder + per-item
  duration), loop/shuffle toggles, and tags
- Live refresh via the playlist_state_changed WebSocket event
- i18n in en/ru/zh

Tests: new unit + API coverage for the store/model, engine (cycling,
single-active exclusivity, missing-preset skip, shuffle, and the
playlist_state_changed event contract), and routes. Full suite green;
ruff and tsc clean.
2026-06-08 13:48:43 +03:00
alexei.dolgolyov ca59546711 feat(capture): region-of-interest (ROI) crop for screen sampling
Sample only a sub-rectangle of the captured frame instead of the whole display,
so a taskbar, game HUD, or letterbox bars don't pollute the border colours — the
first functional gap a reviewer hits (capture was full-display only).

- New pure crop_screen_capture() returns a numpy view (no copy), fast-paths the
  full-frame case, and clamps degenerate/out-of-range ROIs to >=1px.
- ROI lives on CalibrationConfig (simple mode) as fractions 0..1 with a has_roi
  helper; applied in the picture color-strip stream just before border
  extraction, clamping border_width to the cropped size. Additive + backward
  compatible (full-frame default, omitted from serialization when unset -> no
  migration).
- Round-trips through the calibration schema automatically; frontend adds an
  X/Y/Width/Height (%) 'Capture region' group to the calibration editor with
  i18n (en/ru/zh).

10 unit tests (crop geometry, view-not-copy, clamping, ROI round-trip, legacy
default); full suite green (1946 passed).
2026-06-05 11:58:26 +03:00
alexei.dolgolyov 4a82595f26 Merge feat/roadmap-quick-wins: WLED realtime UDP, look presets, weekday/timezone scheduling 2026-06-05 11:44:38 +03:00
alexei.dolgolyov 1ada5ac334 feat(automations): weekday + timezone scheduling for time-of-day rule
Extend the time-of-day condition from a bare server-local HH:MM window to a real
schedule: pick which weekdays it is active (0=Mon..6=Sun, empty = every day) and
an optional IANA timezone (empty = server local). Closes the parity gap where
even a $5 WLED chip has weekday timers.

- Overnight windows (start > end) count toward the day they START on, so the
  after-midnight tail is matched against the previous weekday.
- Timezones are resolved via zoneinfo, cached, and fall back to server-local
  with a one-time warning on an invalid name (the ~1Hz tick never log-spams).
- Backward compatible: new fields default to all-days / server-local, so
  existing automations are unchanged (no migration).
- Frontend: weekday chips + timezone input on the rule editor, day/timezone in
  the rule summary, styles + i18n (en/ru/zh).

10 unit tests (weekday filter, overnight start-day semantics, tz fallback,
round-trip, invalid-day filtering); full suite green (1936 passed).
(Geographic sunrise/sunset triggers are a natural follow-up — the daylight
value source already has the solar math to reuse.)
2026-06-04 23:54:03 +03:00
alexei.dolgolyov e18d56c838 feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool)
Seed five curated, read-only post-processing templates so a non-expert gets
instant good-looking output before discovering the filter pipeline. Each is an
opinionated chain of existing filters (auto-crop/saturation/contrast/colour-
temperature/temporal-blur) tuned for a use case (films, games, evening ambience,
low-flicker, crisp cool-white).

Mirrors the built-in-gradient pattern: adds is_builtin to PostprocessingTemplate,
seeds missing looks on store init (idempotent, additive — no migration), and
makes built-ins read-only (update/delete raise -> 400; clone to customise).
Surfaced via the existing template picker + is_builtin in the response/type.

7 unit tests (seeding, idempotency, read-only protection, round-trip); full
suite green (1926 passed). (A runtime intensity slider is a follow-up — it needs
a filter-chain parameterisation layer.)
2026-06-04 23:43:11 +03:00
alexei.dolgolyov 7728aecb4f feat(wled): native realtime UDP output (DRGB/DRGBW/DNRGB) with auto-revert
Add WLED's native realtime UDP protocol (port 21324) as a third output mode for
LED targets, alongside DDP and HTTP. For the device LedGrab drives most, this
brings three user-visible wins DDP lacks:

- Auto-revert: every packet carries a timeout byte, so if the stream stops
  (host hiccup/sleep/crash) WLED returns to its preset instead of freezing on
  the last frame.
- Correct RGBW whites: the DRGBW variant carries an explicit white channel.
- Lighter on weak Wi-Fi: raw RGB with a 2-byte header.

New WledRealtimeClient auto-selects DRGB (<=490), DRGBW (<=367), or chunked
DNRGB (>490). WLED applies its own per-bus colour order in realtime mode, so we
send plain RGB and the user's colour-order config just works. Protocol 'udp' is
threaded through WLEDConfig/provider/processor and the schema pattern; the
target editor gains a protocol option + badge + i18n (en/ru/zh).

8 unit tests for the packet builder; full suite green (1919 passed).
2026-06-04 23:34:26 +03:00
alexei.dolgolyov e28ab5a956 Merge feat/power-budget-abl: automatic brightness limiting (ABL) / power budget 2026-06-04 23:22:18 +03:00
alexei.dolgolyov 1e395fd09e Merge fix/verified-bugs: weak default key, broken MQTT route, scene brightness sync 2026-06-04 23:22:18 +03:00
alexei.dolgolyov ffee156c17 feat(targets): automatic brightness limiting (ABL) / per-LED power budget
Cap an addressable strip's estimated current draw to a PSU budget so bright/
white scenes can't brown out an under-spec'd supply (voltage sag -> red/orange
shift, flicker, controller resets) — a classic 'it's broken' first impression.

- New core/processing/power_limit.py: pure current estimate (full white over N
  LEDs draws N * mA_per_led) + a (0,1] scale to land a frame on budget.
- Applied in WledTargetProcessor._send_to_device (single choke point, every send
  path; scales into a reusable scratch buffer, never mutates shared frames).
- Two per-target fields on LED targets: max_milliamps (0 = unlimited) and
  milliamps_per_led (default 55), threaded through model/store/manager/processor/
  schema/route with hot-update via update_target_settings. Additive with safe
  defaults (no data migration needed; legacy targets read as unlimited).
- Frontend: editor fields + i18n (en/ru/zh) + LedOutputTarget type.
- Tests: 10 unit tests for the estimator/scale; full suite green (1911 passed).
2026-06-04 22:56:50 +03:00
87 changed files with 11408 additions and 72 deletions
+156 -2
View File
@@ -42,6 +42,7 @@ Complete REST + WebSocket API reference for the LedGrab server.
- [Weather sources](#weather-sources)
- [Automations](#automations)
- [Scene presets](#scene-presets)
- [Scene playlists](#scene-playlists)
- [Sync clocks](#sync-clocks)
- [Webhooks](#webhooks)
- [HTTP endpoints](#http-endpoints)
@@ -184,7 +185,7 @@ Server configuration: MQTT broker, external URL, shutdown action, log level, ADB
## 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 |
| ------ | ---- | ----------- |
@@ -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. |
| 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). |
| 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
@@ -237,7 +251,7 @@ A single aggregated poll endpoint for low-overhead clients.
| 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
@@ -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}/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
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/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 063). `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
App-level routes served by FastAPI (not under `/api/v1`).
+42 -1
View File
@@ -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
+2 -1
View File
@@ -17,7 +17,8 @@ auth:
# 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
# LAN access to anyone on the network.
api_keys: {}
api_keys:
default: "development-key-change-in-production"
# api_keys:
# my-client: "replace-with-output-of-openssl-rand-hex-32"
+6
View File
@@ -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.automations import router as automations_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.sync_clocks import router as sync_clocks_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.snapshot import router as snapshot_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.include_router(system_router)
@@ -53,6 +56,7 @@ router.include_router(output_targets_router)
router.include_router(output_targets_control_router)
router.include_router(automations_router)
router.include_router(scene_presets_router)
router.include_router(scene_playlists_router)
router.include_router(webhooks_router)
router.include_router(sync_clocks_router)
router.include_router(cspt_router)
@@ -70,5 +74,7 @@ router.include_router(pattern_templates_router)
router.include_router(preferences_router)
router.include_router(snapshot_router)
router.include_router(graph_router)
router.include_router(calibration_router)
router.include_router(setup_router)
__all__ = ["router"]
+14
View File
@@ -19,6 +19,7 @@ from ledgrab.storage.audio_template_store import AudioTemplateStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.storage.automation_store import AutomationStore
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.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
@@ -27,6 +28,7 @@ from ledgrab.storage.gradient_store import GradientStore
from ledgrab.storage.weather_source_store import WeatherSourceStore
from ledgrab.storage.asset_store import AssetStore
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.backup.auto_backup import AutoBackupEngine
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
@@ -110,6 +112,14 @@ def get_automation_engine() -> AutomationEngine:
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:
return _get("auto_backup_engine", "Auto-backup engine")
@@ -226,7 +236,9 @@ def init_dependencies(
value_source_store: ValueSourceStore | None = None,
automation_store: AutomationStore | None = None,
scene_preset_store: ScenePresetStore | None = None,
scene_playlist_store: ScenePlaylistStore | None = None,
automation_engine: AutomationEngine | None = None,
playlist_engine: PlaylistEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None,
sync_clock_store: SyncClockStore | None = None,
sync_clock_manager: SyncClockManager | None = None,
@@ -262,7 +274,9 @@ def init_dependencies(
"value_source_store": value_source_store,
"automation_store": automation_store,
"scene_preset_store": scene_preset_store,
"scene_playlist_store": scene_playlist_store,
"automation_engine": automation_engine,
"playlist_engine": playlist_engine,
"auto_backup_engine": auto_backup_engine,
"sync_clock_store": sync_clock_store,
"sync_clock_manager": sync_clock_manager,
@@ -52,6 +52,8 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
"time_of_day": lambda: TimeOfDayRule(
start_time=s.start_time or "00:00",
end_time=s.end_time or "23:59",
days_of_week=s.days_of_week or [],
timezone=s.timezone or "",
),
"system_idle": lambda: SystemIdleRule(
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,
)
@@ -70,6 +70,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
max_milliamps=target.max_milliamps,
milliamps_per_led=target.milliamps_per_led,
description=target.description,
tags=target.tags,
icon=getattr(target, "icon", "") or "",
@@ -302,6 +304,8 @@ async def create_target(
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
max_milliamps=data.max_milliamps,
milliamps_per_led=data.milliamps_per_led,
)
case HALightOutputTargetCreate():
if data.source_kind == "color_vs":
@@ -464,6 +468,8 @@ async def update_target(
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
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
brightness_changed = data.brightness is not None
@@ -476,6 +482,8 @@ async def update_target(
data.min_brightness_threshold,
data.adaptive_fps,
data.brightness,
data.max_milliamps,
data.milliamps_per_led,
)
)
device_changed = data.device_id is not None
@@ -51,6 +51,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
tags=t.tags,
icon=getattr(t, "icon", "") 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".
"""
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
@@ -38,6 +39,7 @@ router = APIRouter()
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
_NOTIFICATION_PREFS_KEY = "notification_preferences"
_CARD_MODES_KEY = "card_modes"
_ONBOARDING_KEY = "onboarded"
class DaylightTimezonePreference(BaseModel):
@@ -285,4 +287,75 @@ async def put_daylight_timezone_preference(
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"]
@@ -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())
+330
View File
@@ -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,
)
+19 -1
View File
@@ -30,7 +30,9 @@ from ledgrab.api.dependencies import (
get_color_strip_store,
get_device_store,
get_output_target_store,
get_playlist_engine,
get_processor_manager,
get_scene_playlist_store,
get_scene_preset_store,
get_sync_clock_manager,
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 .devices import list_devices, resolve_device_brightness
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 .sync_clocks import list_sync_clocks
from .system import get_system_performance, health_check
@@ -53,7 +56,9 @@ logger = get_logger(__name__)
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 = (
"targets",
"target_states",
@@ -63,6 +68,7 @@ SNAPSHOT_SECTIONS = (
"css_sources",
"value_sources",
"scene_presets",
"scene_playlists",
"sync_clocks",
"system",
)
@@ -135,6 +141,8 @@ async def get_snapshot(
css_store=Depends(get_color_strip_store),
value_store=Depends(get_value_source_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_manager=Depends(get_sync_clock_manager),
update_service=Depends(get_update_service),
@@ -152,6 +160,8 @@ async def get_snapshot(
"css_sources": [...],
"value_sources": [...],
"scene_presets": [...],
"scene_playlists": [...],
"playlist_state": {...}, # companion to scene_playlists
"sync_clocks": [...],
"system": {"performance": {...}, "health": {...}, "update": {...}}
}
@@ -184,6 +194,14 @@ async def get_snapshot(
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
if "scene_presets" in sections:
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:
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
result["sync_clocks"] = clocks.clocks
@@ -30,6 +30,14 @@ class RuleSchema(BaseModel):
# Time-of-day rule fields
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)")
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
idle_minutes: int | None = Field(
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")
+12
View File
@@ -344,6 +344,18 @@ class Calibration(BaseModel):
border_width: int = Field(
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):
@@ -91,7 +91,11 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
adaptive_fps: bool = Field(
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):
@@ -233,8 +237,20 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
)
protocol: str = Field(
default="ddp",
pattern="^(ddp|http)$",
description="Send protocol: ddp (UDP) or http (JSON API)",
pattern="^(ddp|http|udp)$",
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"
)
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,
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):
@@ -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
+63
View File
@@ -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__)
# 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)
class _RuleEvalContext:
@@ -519,16 +546,26 @@ class AutomationEngine:
@staticmethod
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
now = datetime.now()
now = _now_in_tz(rule.timezone)
current = now.hour * 60 + now.minute
parts_s = rule.start_time.split(":")
parts_e = rule.end_time.split(":")
start = int(parts_s[0]) * 60 + int(parts_s[1])
end = int(parts_e[0]) * 60 + int(parts_e[1])
days = rule.days_of_week
if start <= end:
return start <= current <= end
# Overnight range (e.g. 22:00 → 06:00)
return current >= start or current <= end
if not (start <= current <= end):
return False
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
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
@@ -113,6 +113,18 @@ class CalibrationConfig:
skip_leds_end: int = 0
# Border width: how many pixels from the screen edge to sample
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]:
"""Derive segment list from core parameters."""
@@ -656,6 +668,98 @@ def create_pixel_mapper(
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(
led_count: int,
aspect_width: int = 16,
@@ -799,6 +903,10 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0),
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()
@@ -870,4 +978,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["skip_leds_end"] = config.skip_leds_end
if config.border_width != 10:
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
@@ -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}")
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:
"""Extract border pixels from screen capture.
@@ -23,6 +23,11 @@ class BaseDeviceConfig:
class WLEDConfig(BaseDeviceConfig):
device_type: Literal["wled"] = "wled"
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)
+53 -9
View File
@@ -86,6 +86,8 @@ class WLEDClient(LEDClient):
retry_attempts: int = 3,
retry_delay: int = 1,
use_ddp: bool = False,
use_realtime: bool = False,
realtime_timeout: int = 2,
):
"""Initialize WLED client.
@@ -95,12 +97,17 @@ class WLEDClient(LEDClient):
retry_attempts: Number of retry attempts on failure
retry_delay: Delay between retries in seconds
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.timeout = timeout
self.retry_attempts = retry_attempts
self.retry_delay = retry_delay
self.use_ddp = use_ddp
self.use_realtime = use_realtime
self.realtime_timeout = realtime_timeout
# Extract hostname/IP from URL for DDP
parsed = urlparse(self.url)
@@ -108,6 +115,7 @@ class WLEDClient(LEDClient):
self._client: httpx.AsyncClient | None = None
self._ddp_client: DDPClient | None = None
self._realtime_client = None # WledRealtimeClient when use_realtime
self._connected = False
self._pre_connect_state: dict | None = None
@@ -127,8 +135,9 @@ class WLEDClient(LEDClient):
# Test connection by getting device info
info = await self.get_info()
# Auto-enable DDP for large LED counts
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp:
# Auto-enable DDP for large LED counts (unless the user explicitly
# 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(
f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), "
"auto-enabling DDP protocol"
@@ -138,8 +147,30 @@ class WLEDClient(LEDClient):
# Snapshot device state BEFORE any mutations (for auto-restore)
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
if self.use_ddp:
elif self.use_ddp:
self._ddp_client = DDPClient(self.host, rgbw=False)
# Pass per-bus config so DDP client can apply per-bus color reordering
if info.buses:
@@ -191,6 +222,9 @@ class WLEDClient(LEDClient):
if self._ddp_client:
await self._ddp_client.close()
self._ddp_client = None
if self._realtime_client:
await self._realtime_client.close()
self._realtime_client = None
self._connected = False
logger.debug(f"Closed connection to {self.url}")
@@ -201,8 +235,10 @@ class WLEDClient(LEDClient):
@property
def supports_fast_send(self) -> bool:
"""True when DDP is active and ready for fire-and-forget sends."""
return self.use_ddp and self._ddp_client is not None
"""True when DDP or native realtime UDP is active (fire-and-forget)."""
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(
self,
@@ -384,7 +420,10 @@ class WLEDClient(LEDClient):
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
# 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:
return await self._send_pixels_ddp(validated_pixels, brightness)
else:
@@ -485,8 +524,10 @@ class WLEDClient(LEDClient):
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
brightness: Global brightness (0-255)
"""
if not self.use_ddp or not self._ddp_client:
raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP")
if not (self.use_ddp and self._ddp_client) and not (
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):
pixel_array = pixels
@@ -494,7 +535,10 @@ class WLEDClient(LEDClient):
pixel_array = np.array(pixels, dtype=np.uint8)
# 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 =====
@@ -86,6 +86,8 @@ class WLEDDeviceProvider(LEDDeviceProvider):
return WLEDClient(
config.device_url,
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:
@@ -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
@@ -18,7 +18,7 @@ from ledgrab.core.capture.calibration import (
CalibrationConfig,
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.utils import get_logger
from ledgrab.utils.frame_limiter import FrameLimiter
@@ -296,7 +296,19 @@ class PictureColorStripStream(ColorStripStream):
t1 = time.perf_counter()
led_colors = mapper.map_lines_to_leds(frames_dict)
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()
led_colors = mapper.map_border_to_leds(border_pixels)
t2 = time.perf_counter()
@@ -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.processing.device_health import DeviceHealthMixin
from ledgrab.core.processing.device_test_mode import DeviceTestModeMixin
from ledgrab.core.capture.calibration_session import CalibrationChaseMixin
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -106,7 +107,9 @@ class DeviceState:
zone_mode: str = "combined"
class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin):
class ProcessorManager(
AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin, CalibrationChaseMixin
):
"""Manages devices and delegates target processing to TargetProcessor instances.
Devices are registered for health monitoring.
@@ -407,6 +410,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
min_brightness_threshold: int = 0,
adaptive_fps: bool = False,
protocol: str = "ddp",
max_milliamps: int = 0,
milliamps_per_led: int = 55,
):
"""Register a WLED target processor."""
if target_id in self._processors:
@@ -425,6 +430,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
protocol=protocol,
max_milliamps=max_milliamps,
milliamps_per_led=milliamps_per_led,
ctx=self._build_context(),
)
self._processors[target_id] = proc
@@ -17,6 +17,7 @@ from ledgrab.core.devices.led_client import (
get_device_capabilities,
)
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 (
ProcessingMetrics,
TargetContext,
@@ -62,6 +63,8 @@ class WledTargetProcessor(TargetProcessor):
min_brightness_threshold: int = 0,
adaptive_fps: bool = False,
protocol: str = "ddp",
max_milliamps: int = 0,
milliamps_per_led: int = 55,
ctx: TargetContext = None,
):
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._adaptive_fps = adaptive_fps
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
self._effective_fps: int = self._target_fps
@@ -146,9 +156,15 @@ class WledTargetProcessor(TargetProcessor):
from ledgrab.core.devices.device_config import WLEDConfig as _WLEDConfig
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):
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
# Connect to LED device
@@ -313,6 +329,12 @@ class WledTargetProcessor(TargetProcessor):
self._adaptive_fps = settings["adaptive_fps"]
if not self._adaptive_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}")
def update_device(self, device_id: str) -> None:
@@ -787,8 +809,33 @@ class WledTargetProcessor(TargetProcessor):
np.copyto(out, blend, casting="unsafe") # float32 → uint8
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:
"""Send colors to LED device and return send time in ms."""
send_colors = self._apply_power_limit(send_colors)
t_start = time.perf_counter()
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors)
@@ -0,0 +1,280 @@
"""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.
"""
if self._state is not None and self._state.playlist_id == playlist_id:
await self.stop()
# ===== Query API (used by routes) =====
def is_running(self) -> bool:
return self._task is not None and not self._task.done()
def get_running_playlist_id(self) -> str | None:
return self._state.playlist_id if self._state else None
def get_state(self) -> dict:
if self._state is not None and self.is_running():
return self._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). Guard
# against a concurrent start_playlist having already replaced us:
# only clear if we are still the engine's current task.
if self._task is asyncio.current_task():
self._task = None
ended_id = self._state.playlist_id if self._state else None
self._state = None
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)
+17
View File
@@ -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.automation_store import AutomationStore
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.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
@@ -47,6 +48,7 @@ from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
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.core.game_integration.event_bus import GameEventBus
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)
automation_store = AutomationStore(db)
scene_preset_store = ScenePresetStore(db)
scene_playlist_store = ScenePlaylistStore(db)
sync_clock_store = SyncClockStore(db)
cspt_store = ColorStripProcessingTemplateStore(db)
gradient_store = GradientStore(db)
@@ -278,6 +281,15 @@ async def lifespan(app: FastAPI):
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
# demo mode auto-backups go to data/demo/ instead of data/.
_data_dir = Path(config.storage.database_file).parent
@@ -314,7 +326,9 @@ async def lifespan(app: FastAPI):
value_source_store=value_source_store,
automation_store=automation_store,
scene_preset_store=scene_preset_store,
scene_playlist_store=scene_playlist_store,
automation_engine=automation_engine,
playlist_engine=playlist_engine,
auto_backup_engine=auto_backup_engine,
sync_clock_store=sync_clock_store,
sync_clock_manager=sync_clock_manager,
@@ -436,6 +450,9 @@ async def lifespan(app: FastAPI):
# would talk to processors mid-shutdown.
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)
# Stop discovery watcher and OS notification listener so they stop
# firing events into a shutting-down processor manager.
if discovery_watcher is not None:
@@ -152,6 +152,50 @@
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 {
font-size: 0.65rem;
font-weight: 700;
@@ -1134,6 +1134,189 @@ textarea:focus-visible {
cursor: not-allowed;
}
/* ── Scene playlist items — ordered, timed channel rows ──────────
Mirrors .scene-target-* (cyan patch-bay) but adds a per-item dwell
duration field and reorder controls. Slot index via CSS counter so
DOM reorders need no JS renumbering. Paired with
.ds-section[data-ch="cyan"] in scene-playlist-editor.html. */
.playlist-item-list {
--st-ch: var(--ch-cyan, var(--info-color, #00d8ff));
counter-reset: st-slot;
display: flex;
flex-direction: column;
gap: 4px;
padding: 0;
}
.playlist-item-list:empty::before {
content: attr(data-empty);
display: block;
padding: 14px 12px;
font-size: 0.78rem;
color: var(--lux-ink-dim, var(--text-secondary));
border: 1px dashed color-mix(in srgb, var(--st-ch) 40%, var(--lux-line, var(--border-color)));
border-radius: var(--lux-r-md, 6px);
background:
repeating-linear-gradient(135deg,
color-mix(in srgb, var(--st-ch) 4%, transparent) 0 6px,
transparent 6px 12px);
text-align: center;
letter-spacing: 0.04em;
}
.playlist-item {
counter-increment: st-slot;
position: relative;
display: grid;
grid-template-columns: 26px 32px minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 6px 8px 6px 6px;
border: 1px solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 6px);
background:
linear-gradient(180deg,
color-mix(in srgb, var(--st-ch) 3%, var(--lux-bg-2, var(--bg-secondary))) 0%,
color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 70%, transparent) 100%);
font-size: 0.85rem;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.playlist-item:hover {
border-color: color-mix(in srgb, var(--st-ch) 55%, var(--lux-line, var(--border-color)));
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--st-ch) 80%, transparent);
}
.playlist-item::before {
content: counter(st-slot, decimal-leading-zero);
grid-column: 1;
justify-self: center;
font-family: var(--font-mono);
font-size: 0.65rem;
font-weight: 600;
color: color-mix(in srgb, var(--st-ch) 75%, var(--lux-ink-dim, var(--text-secondary)));
opacity: 0.85;
}
.playlist-item-icon {
grid-column: 2;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: var(--st-ch);
background: color-mix(in srgb, var(--st-ch) 9%, transparent);
border: 1px solid color-mix(in srgb, var(--st-ch) 22%, transparent);
border-radius: 5px;
flex-shrink: 0;
}
.playlist-item-icon svg,
.playlist-item-icon .icon { width: 18px; height: 18px; }
.playlist-item-icon .scene-color-dot { width: 12px; height: 12px; border-radius: 50%; }
.playlist-item-id {
grid-column: 3;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.playlist-item-name {
font-weight: 600;
color: var(--lux-ink, var(--text-color));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
}
.playlist-item-type {
align-self: flex-start;
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: color-mix(in srgb, var(--st-ch) 70%, var(--lux-ink-dim, var(--text-secondary)));
padding: 1px 5px;
border: 1px solid color-mix(in srgb, var(--st-ch) 28%, transparent);
border-radius: 2px;
line-height: 1.2;
}
.playlist-item-type.playlist-item--missing {
color: var(--ch-coral, var(--danger-color, #ff5e5e));
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 40%, transparent);
}
.playlist-item-duration-wrap {
grid-column: 4;
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--lux-ink-dim, var(--text-secondary));
}
.playlist-item-duration-wrap svg,
.playlist-item-duration-wrap .icon { width: 13px; height: 13px; opacity: 0.7; }
/* Scope + attribute-qualify so this beats the global `input[type="number"]`
rule (specificity 0,1,1) which would otherwise force width:100% and collapse
the minmax(0,1fr) name column to zero. Same approach as `.schedule-time-wrap`. */
.playlist-item-duration-wrap input[type="number"].playlist-item-duration {
width: 52px;
padding: 3px 5px;
text-align: right;
font-family: var(--font-mono);
font-size: 0.8rem;
border: 1px solid var(--lux-line, var(--border-color));
border-radius: 4px;
background: var(--bg-color, transparent);
color: var(--lux-ink, var(--text-color));
-moz-appearance: textfield;
}
.playlist-item-duration::-webkit-inner-spin-button,
.playlist-item-duration::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.playlist-item-duration-wrap input[type="number"].playlist-item-duration:focus {
outline: none;
border-color: color-mix(in srgb, var(--st-ch) 60%, var(--lux-line, var(--border-color)));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--st-ch) 18%, transparent);
}
.playlist-item-unit {
font-family: var(--font-mono);
font-size: 0.72rem;
opacity: 0.7;
}
.playlist-item-actions {
grid-column: 5;
display: inline-flex;
gap: 2px;
}
.playlist-item-btn {
background: none;
border: 1px solid transparent;
color: var(--lux-ink-dim, var(--text-secondary));
width: 24px;
height: 26px;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0.75;
font-size: 0.9rem;
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.playlist-item-btn:hover,
.playlist-item-btn:focus-visible {
opacity: 1;
color: var(--st-ch);
background: color-mix(in srgb, var(--st-ch) 10%, transparent);
border-color: color-mix(in srgb, var(--st-ch) 30%, transparent);
outline: none;
}
.playlist-item-btn.playlist-item-remove:hover,
.playlist-item-btn.playlist-item-remove:focus-visible {
color: var(--ch-coral, var(--danger-color, #ff5e5e));
background: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 10%, transparent);
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 35%, transparent);
}
.playlist-item-btn .icon,
.playlist-item-btn svg { width: 14px; height: 14px; }
/* ── Icon Select (reusable type picker) ──────────────────────── */
.icon-select-trigger {
@@ -2100,3 +2283,767 @@ textarea:focus-visible {
.pair-ring-fg { transition: none; }
}
/* =========================================================
Auto-Calibration Wizard
========================================================= */
/* Step wrapper */
.autocal-step {
display: flex;
flex-direction: column;
gap: 20px;
}
.autocal-step-header {
display: flex;
align-items: center;
gap: 12px;
}
.autocal-step-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
color: var(--primary-color);
flex-shrink: 0;
}
.autocal-step-icon .icon { width: 18px; height: 18px; }
.autocal-step-icon--ok {
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, transparent);
color: var(--success-color, #4caf50);
}
.autocal-step-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
}
.autocal-step-desc {
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
line-height: 1.5;
margin: 0;
}
/* Corner selection grid (2x2) */
.autocal-corner-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.autocal-corner-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px 12px;
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md, 8px);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
font-size: 0.82rem;
font-weight: 500;
text-align: center;
}
.autocal-corner-btn:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color);
}
.autocal-corner-btn:active {
background: color-mix(in srgb, var(--primary-color) 18%, var(--card-bg));
}
/* Spatial corner indicator: a mini "screen" frame with the matching
corner lit, so the user maps the physical lit LED to a button at a glance. */
.autocal-corner-glyph {
position: relative;
width: 46px;
height: 30px;
border: 1.5px solid var(--border-color);
border-radius: 4px;
background: color-mix(in srgb, var(--primary-color) 4%, var(--card-bg));
transition: border-color 0.15s, background 0.15s;
}
.autocal-corner-glyph::after {
content: '';
position: absolute;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--border-color);
transition: background 0.15s, box-shadow 0.15s;
}
.autocal-corner-btn--top-left .autocal-corner-glyph::after { top: 4px; left: 4px; }
.autocal-corner-btn--top-right .autocal-corner-glyph::after { top: 4px; right: 4px; }
.autocal-corner-btn--bottom-left .autocal-corner-glyph::after { bottom: 4px; left: 4px; }
.autocal-corner-btn--bottom-right .autocal-corner-glyph::after { bottom: 4px; right: 4px; }
/* Light the dot on hover/focus so the active target reads as "this corner". */
.autocal-corner-btn:hover .autocal-corner-glyph,
.autocal-corner-btn:focus-visible .autocal-corner-glyph {
border-color: var(--primary-color);
}
.autocal-corner-btn:hover .autocal-corner-glyph::after,
.autocal-corner-btn:focus-visible .autocal-corner-glyph::after {
background: var(--primary-color);
box-shadow: 0 0 7px color-mix(in srgb, var(--primary-color) 75%, transparent);
}
/* Direction selection grid (1x2) */
.autocal-direction-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.autocal-direction-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px 12px;
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md, 8px);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
font-size: 0.82rem;
font-weight: 500;
text-align: center;
}
.autocal-direction-btn:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color);
}
.autocal-direction-btn .icon { width: 28px; height: 28px; }
.autocal-corner-btn[disabled], .autocal-direction-btn[disabled] { opacity: .5; cursor: default; pointer-events: none; }
/* LED indicator (live LED preview row) */
.autocal-led-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: color-mix(in srgb, var(--primary-color) 6%, var(--card-bg));
border: 1px solid color-mix(in srgb, var(--primary-color) 20%, var(--border-color));
border-radius: var(--radius-sm, 6px);
font-size: 0.8rem;
color: var(--text-muted, var(--secondary-text-color));
}
.autocal-led-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--border-color);
transition: background 0.2s;
flex-shrink: 0;
}
.autocal-led-dot--active {
background: var(--primary-color);
box-shadow: 0 0 6px color-mix(in srgb, var(--primary-color) 70%, transparent);
}
.autocal-led-index {
font-weight: 600;
color: var(--primary-color);
min-width: 28px;
text-align: right;
}
/* Corner marking progress (step 4) */
.autocal-corners-progress {
display: flex;
flex-direction: column;
gap: 14px;
}
.autocal-pips {
display: flex;
gap: 8px;
align-items: center;
}
.autocal-pip {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--border-color);
background: var(--card-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted, var(--secondary-text-color));
transition: border-color 0.2s, background 0.2s, color 0.2s;
flex-shrink: 0;
}
.autocal-pip--done {
border-color: var(--success-color, #4caf50);
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, var(--card-bg));
color: var(--success-color, #4caf50);
}
.autocal-pip--active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 15%, var(--card-bg));
color: var(--primary-color);
}
.autocal-index-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.78rem;
font-weight: 600;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
}
/* LED sweep row */
.autocal-sweep-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
}
.autocal-led-track {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--border-color);
position: relative;
overflow: hidden;
}
.autocal-led-track-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: var(--primary-color);
border-radius: 3px;
transition: width 0.1s linear;
}
.autocal-led-cursor {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--primary-color);
box-shadow: 0 0 8px color-mix(in srgb, var(--primary-color) 80%, transparent);
pointer-events: none;
}
.autocal-sweep-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1.5px solid var(--border-color);
border-radius: var(--radius-sm, 6px);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
flex-shrink: 0;
}
.autocal-sweep-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.autocal-sweep-btn .icon { width: 16px; height: 16px; }
.autocal-mark-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: var(--radius-md, 8px);
background: var(--primary-color);
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: opacity 0.15s;
white-space: nowrap;
}
.autocal-mark-btn:hover { opacity: 0.88; }
.autocal-mark-btn .icon { width: 15px; height: 15px; }
/* Preview / solved grid */
.autocal-solved-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
padding: 14px 16px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
font-size: 0.82rem;
}
.autocal-solved-item {
display: flex;
align-items: baseline;
gap: 6px;
}
.autocal-solved-item--wide {
grid-column: 1 / -1;
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
margin-bottom: 4px;
}
.autocal-solved-key {
color: var(--text-muted, var(--secondary-text-color));
flex-shrink: 0;
min-width: 68px;
}
.autocal-solved-val {
font-weight: 600;
color: var(--text-color);
}
.autocal-led-count {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 7px;
border-radius: 10px;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
font-size: 0.78rem;
font-weight: 700;
}
/* Footer row (wizard nav buttons) */
.autocal-footer {
display: flex;
align-items: center;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border-color);
flex-wrap: wrap;
}
.autocal-footer > .btn { min-width: 80px; }
.autocal-footer > .btn:first-child { margin-right: auto; }
/* Inline error */
.autocal-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-sm, 6px);
background: color-mix(in srgb, var(--danger-color, #f44336) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--danger-color, #f44336) 30%, transparent);
color: var(--danger-color, #f44336);
font-size: 0.82rem;
line-height: 1.4;
}
.autocal-error .icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
/* "Auto-calibrate" trigger button in calibration modal footer */
.autocal-trigger-btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.autocal-trigger-btn .icon { width: 14px; height: 14px; }
@media (prefers-reduced-motion: reduce) {
.autocal-led-track-fill,
.autocal-pip,
.autocal-led-dot,
.autocal-corner-btn,
.autocal-direction-btn { transition: none; }
}
/* ==========================================================
Setup Wizard (features/setup-wizard.ts)
========================================================= */
/* Progress bar */
.wizard-progress-bar {
margin-bottom: 6px;
}
.wizard-progress-track {
height: 3px;
background: var(--border-color);
border-radius: 2px;
overflow: hidden;
}
.wizard-progress-fill {
height: 100%;
background: var(--primary-color);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Pip indicators */
.wizard-progress-labels {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.wizard-pip {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
background: var(--bg-secondary, var(--bg-2, #2a2a2a));
color: var(--text-muted, var(--secondary-text-color));
border: 1.5px solid var(--border-color);
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.wizard-pip--done {
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
color: var(--primary-color);
border-color: var(--primary-color);
}
.wizard-pip--done .icon { width: 12px; height: 12px; }
.wizard-pip--active {
background: var(--primary-color);
color: #fff;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 25%, transparent);
}
/* Step layout */
.wizard-step {
display: flex;
flex-direction: column;
gap: 18px;
}
.wizard-step-header {
display: flex;
align-items: flex-start;
gap: 14px;
}
.wizard-step-icon {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
color: var(--primary-color);
flex-shrink: 0;
}
.wizard-step-icon .icon { width: 18px; height: 18px; }
.wizard-step-icon--ok {
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, transparent);
color: var(--success-color, #4caf50);
}
.wizard-step-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin: 0 0 4px;
}
.wizard-step-desc {
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
line-height: 1.5;
margin: 0;
}
/* Welcome step */
.wizard-step--welcome {
align-items: center;
text-align: center;
padding: 8px 0;
}
.wizard-welcome-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
margin-bottom: 4px;
}
.wizard-welcome-icon .icon { width: 32px; height: 32px; }
.wizard-welcome-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
width: 100%;
max-width: 360px;
}
.wizard-welcome-list li {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.88rem;
color: var(--text-color);
padding: 8px 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm, 6px);
}
.wizard-welcome-list li .icon { width: 16px; height: 16px; color: var(--primary-color); flex-shrink: 0; }
/* Discovery section */
.wizard-discovery-section { display: flex; flex-direction: column; gap: 8px; }
.wizard-section-label {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, var(--secondary-text-color));
padding-bottom: 4px;
}
.wizard-section-label--scan {
display: flex;
align-items: center;
justify-content: space-between;
}
.wizard-scan-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
font-weight: 600;
color: var(--primary-color);
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm, 4px);
transition: background 0.15s;
}
.wizard-scan-btn:hover { background: color-mix(in srgb, var(--primary-color) 10%, transparent); }
.wizard-scan-btn .icon { width: 12px; height: 12px; }
.wizard-discovery-scanning {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 12px;
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
}
.wizard-discovery-empty {
padding: 14px 12px;
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
}
.wizard-discovery-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.wizard-discovery-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--card-bg);
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md, 8px);
cursor: pointer;
text-align: left;
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.wizard-discovery-item:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
}
.wizard-discovery-icon { color: var(--primary-color); flex-shrink: 0; }
.wizard-discovery-icon .icon { width: 20px; height: 20px; }
.wizard-discovery-details { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.wizard-discovery-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-badge {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 10px;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
flex-shrink: 0;
}
/* Display list */
.wizard-display-list { display: flex; flex-direction: column; gap: 6px; }
.wizard-display-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--card-bg);
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md, 8px);
cursor: pointer;
text-align: left;
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.wizard-display-item:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
}
.wizard-display-item--active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
}
.wizard-display-icon { color: var(--primary-color); flex-shrink: 0; }
.wizard-display-icon .icon { width: 20px; height: 20px; }
.wizard-display-details { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.wizard-display-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); }
.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); }
.wizard-display-check { color: var(--primary-color); }
.wizard-display-check .icon { width: 16px; height: 16px; }
.wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; }
/* Scaffold / start progress */
.wizard-scaffold-progress {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
}
.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted, var(--secondary-text-color)); }
/* Calibrate container */
.wizard-calibrate-container {
min-height: 80px;
}
/* Done step */
.wizard-step--done {
align-items: center;
text-align: center;
padding: 8px 0;
}
.wizard-done-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, transparent);
color: var(--success-color, #4caf50);
margin-bottom: 4px;
}
.wizard-done-icon .icon { width: 32px; height: 32px; }
.wizard-done-summary {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
max-width: 360px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
padding: 12px 16px;
}
.wizard-done-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; gap: 12px; }
.wizard-done-label { color: var(--text-muted, var(--secondary-text-color)); }
.wizard-done-value { font-weight: 600; color: var(--text-color); text-align: right; }
/* Wizard form rows */
.wizard-form-row { display: flex; flex-direction: column; gap: 6px; }
.wizard-form-label { font-size: 0.82rem; font-weight: 600; color: var(--text-color); }
/* Error banner */
.wizard-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-sm, 6px);
background: color-mix(in srgb, var(--danger-color, #f44336) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--danger-color, #f44336) 30%, transparent);
color: var(--danger-color, #f44336);
font-size: 0.82rem;
line-height: 1.4;
}
.wizard-error .icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
/* Footer (nav buttons) */
.wizard-footer {
display: flex;
align-items: center;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border-color);
flex-wrap: wrap;
}
.wizard-footer > .btn:first-child { margin-right: auto; }
.wizard-footer--done { justify-content: center; border-top: none; padding-top: 0; }
.wizard-footer--done > .btn:first-child { margin-right: 0; }
/* Btn spinner (inline in disabled state) */
.btn-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-right: 6px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.wizard-progress-fill,
.wizard-pip,
.wizard-discovery-item,
.wizard-display-item { transition: none; }
.btn-spinner { animation: none; }
}
+78 -2
View File
@@ -36,7 +36,16 @@ import {
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
startIntegrationsTutorial,
closeTutorial, tutorialNext, tutorialPrev,
TOUR_KEY,
} 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
import {
@@ -116,6 +125,11 @@ import {
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
addSceneTarget,
} 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
import {
@@ -198,12 +212,21 @@ import {
updateOffsetSkipLock, updateCalibrationPreview,
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
showCSSCalibration, toggleCalibrationOverlay,
openAutoCalFromCalibration,
} from './features/calibration.ts';
import {
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
updateCalibrationLine, resetCalibrationView,
} 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
import {
@@ -315,6 +338,21 @@ Object.assign(window, {
selectDisplay,
formatDisplayLabel,
// setup wizard
openSetupWizard,
closeSetupWizard,
wizardNext,
wizardBack,
wizardSkip,
wizardFinish,
wizardShowManual,
wizardHideManual,
wizardRescan,
wizardSelectDiscovered,
wizardAddManualDevice,
wizardUseExistingDevice,
wizardSelectDisplay,
// tutorials
startCalibrationTutorial,
startDeviceTutorial,
@@ -463,6 +501,17 @@ Object.assign(window, {
recaptureScenePreset,
addSceneTarget,
// scene playlists — modal buttons + mod-card inline handlers
openPlaylistEditor,
editPlaylist,
savePlaylist,
closePlaylistEditor,
clonePlaylist,
deletePlaylist,
addPlaylistItem,
startScenePlaylist,
stopScenePlaylist,
// integrations
loadIntegrations,
switchIntegrationTab,
@@ -604,6 +653,24 @@ Object.assign(window, {
toggleTestEdge,
showCSSCalibration,
toggleCalibrationOverlay,
openAutoCalFromCalibration,
// auto-calibration wizard
showAutoCalibration,
closeAutoCalModal,
autoCalSelectDevice,
autoCalSetCorner,
autoCalSetDirection,
autoCalBackToCorner,
autoCalBackToDirection,
autoCalSweepForward,
autoCalSweepBack,
autoCalMarkCorner,
autoCalSolve,
autoCalSave,
autoCalCancel,
mountAutoCalibration,
unmountAutoCalibration,
// advanced calibration
showAdvancedCalibration,
@@ -908,8 +975,17 @@ document.addEventListener('DOMContentLoaded', async () => {
setProjectUrls(serverRepoUrl, serverDonateUrl);
initDonationBanner();
// Show getting-started tutorial on first visit
if (!localStorage.getItem('tour_completed')) {
// First-run: wizard wins over the tooltip tour.
//
// 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);
}
} catch (err) {
@@ -8,7 +8,7 @@
import {
devicesCache, outputTargetsCache, colorStripSourcesCache,
streamsCache, audioSourcesCache, valueSourcesCache,
syncClocksCache, automationsCacheObj, scenePresetsCache,
syncClocksCache, automationsCacheObj, scenePresetsCache, scenePlaylistsCache,
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
patternTemplatesCache,
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
@@ -26,6 +26,7 @@ const ENTITY_CACHE_MAP = {
sync_clock: syncClocksCache,
automation: automationsCacheObj,
scene_preset: scenePresetsCache,
scene_playlist: scenePlaylistsCache,
capture_template: captureTemplatesCache,
audio_template: audioTemplatesCache,
pp_template: ppTemplatesCache,
@@ -51,6 +52,7 @@ const ENTITY_LOADER_MAP = {
pp_template: 'loadPictureSources',
automation: 'loadAutomations',
scene_preset: 'loadAutomations',
scene_playlist: 'loadAutomations',
weather_source: 'loadIntegrations',
home_assistant_source: 'loadIntegrations',
mqtt_source: 'loadIntegrations',
@@ -40,6 +40,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
'server_restarting',
'state_change',
'automation_state_changed',
'playlist_state_changed',
'entity_changed',
'device_health_changed',
'update_available',
+6 -1
View File
@@ -15,7 +15,7 @@
import { DataCache } from './cache.ts';
import type {
Device, OutputTarget, ColorStripSource, PatternTemplate,
ValueSource, AudioSource, PictureSource, ScenePreset,
ValueSource, AudioSource, PictureSource, ScenePreset, ScenePlaylist,
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
@@ -436,6 +436,11 @@ export const scenePresetsCache = new DataCache<ScenePreset[]>({
extractData: json => json.presets || [],
});
export const scenePlaylistsCache = new DataCache<ScenePlaylist[]>({
endpoint: '/scene-playlists',
extractData: json => json.playlists || [],
});
export interface GradientEntity {
id: string;
name: string;
@@ -0,0 +1,810 @@
/**
* 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;
// ── 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();
_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;
}
}
// ── 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);
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
await _setPosition(advance);
_state.busy = false;
} catch (err: unknown) {
_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'))}">&#8592;</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'))}">&#8594;</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 {
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
scenePresetsCache, _cachedHASources, haSourcesCache,
scenePresetsCache, scenePlaylistsCache, _cachedHASources, haSourcesCache,
_cachedValueSources, valueSourcesCache,
getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts';
@@ -18,7 +18,7 @@ import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge, updateSubTabHash } from './tabs.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 { wrapCard } from '../core/card-colors.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 { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import { csPlaylists, createPlaylistCard, initPlaylistDelegation } from './scene-playlists.ts';
import type { Automation, RuleType } from '../types.ts';
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
@@ -252,6 +253,7 @@ export async function loadAutomations() {
const [automations, scenes] = await Promise.all([
automationsCacheObj.fetch(),
scenePresetsCache.fetch(),
scenePlaylistsCache.fetch(),
haSourcesCache.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 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 treeItems = [
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.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()) {
_automationsTree.updateCounts({
automations: automations.length,
scenes: scenePresetsCache.data.length,
playlists: scenePlaylistsCache.data.length,
});
csAutomations.reconcile(autoItems);
csScenes.reconcile(sceneItems);
csPlaylists.reconcile(playlistItems);
} else {
const panels = [
{ key: 'automations', html: csAutomations.render(autoItems) },
{ 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('');
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!);
initPlaylistDelegation(container!);
_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.observeSections('automations-content', {
'automations': 'automations',
'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'));
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
},
time_of_day: (c) => ({
icon: ICON_CLOCK,
text: `${c.start_time || '00:00'} ${c.end_time || '23:59'}`,
title: t('automations.rule.time_of_day'),
}),
time_of_day: (c) => {
const days: number[] = Array.isArray(c.days_of_week) ? c.days_of_week : [];
let text = `${c.start_time || '00:00'} ${c.end_time || '23:59'}`;
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) => {
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') };
@@ -878,6 +891,11 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number);
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 = `
<div class="rule-fields">
<input type="hidden" class="rule-start-time" value="${startTime}">
@@ -901,9 +919,21 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
</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>
</div>`;
_wireTimeRangePicker(container);
container.querySelectorAll('.weekday-chip').forEach((chip) => {
chip.addEventListener('click', () => chip.classList.toggle('active'));
});
}
function _renderSystemIdleFields(container: HTMLElement, data: any): void {
@@ -1314,6 +1344,9 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
rule_type: 'time_of_day',
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
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) => ({
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 { EntitySelect } from '../core/entity-palette.ts';
import type { Calibration } from '../types.ts';
import { showAutoCalibration } from './auto-calibration.ts';
let _calTestDeviceEntitySelect: EntitySelect | null = null;
let _calTestDeviceList: any[] = [];
@@ -41,6 +42,10 @@ class CalibrationModal extends Modal {
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
skip_end: (this.$('cal-skip-end') 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,
};
}
@@ -173,6 +178,7 @@ export async function showCalibration(deviceId: any) {
updateOffsetSkipLock();
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
_populateRoiInputs(calibration);
window.edgeSpans = {
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
@@ -228,6 +234,33 @@ export async function closeCalibrationModal() {
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 ──────────────────────────────────── */
export async function showCSSCalibration(cssId: any) {
@@ -319,6 +352,7 @@ export async function showCSSCalibration(cssId: any) {
updateOffsetSkipLock();
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
_populateRoiInputs(calibration);
window.edgeSpans = {
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
@@ -882,6 +916,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() {
const cssMode = _isCSS();
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
@@ -936,6 +984,10 @@ export async function saveCalibration() {
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'),
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 {
@@ -60,6 +60,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [
'integrations',
'automations',
'scenes',
'playlists',
'sync-clocks',
'targets',
] as const;
@@ -69,6 +70,7 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
integrations: 'dashboard.section.integrations',
automations: 'dashboard.section.automations',
scenes: 'dashboard.section.scenes',
playlists: 'dashboard.section.playlists',
'sync-clocks': 'dashboard.section.sync_clocks',
targets: 'dashboard.section.targets',
};
@@ -22,6 +22,7 @@ export type SectionKey =
| 'integrations'
| 'automations'
| 'scenes'
| 'playlists'
| 'sync-clocks'
| 'targets'
// Reserved registry keys for v1.1+ (so saved layouts forward-compat).
@@ -151,6 +152,7 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
_defaultSection('integrations'),
_defaultSection('automations'),
_defaultSection('scenes'),
_defaultSection('playlists'),
_defaultSection('sync-clocks'),
_defaultSection('targets'),
],
@@ -192,7 +194,7 @@ export const PRESETS: Record<string, () => DashboardLayoutV1> = {
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 => ({ ...s, density: 'compact' }));
return l;
@@ -15,6 +15,7 @@ import {
ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS,
} from '../core/icons.ts';
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
import { loadPlaylists } from './scene-playlists.ts';
import { cardColorStyle } from '../core/card-colors.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
@@ -55,7 +56,7 @@ function _mountDashboardCardModeToggles(): void {
_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 MAX_FPS_SAMPLES = 120;
@@ -529,6 +530,49 @@ function renderDashboardSyncClock(clock: SyncClock): string {
</div>`;
}
/** Compact dashboard card for a scene playlist. Mirrors the sync-clock card:
* running state drives the LED / patch indicator and the StartStop 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;
/** Called from the transport-bar poll cycler (and any legacy callers
* that might still reference `window.changeDashboardPollInterval`). */
@@ -644,7 +688,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
try {
// 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[] => []),
fetchWithAuth('/automations').catch(() => null),
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/metrics').catch(() => null),
loadScenePresets(),
loadPlaylists().catch((): ScenePlaylist[] => []),
fetchWithAuth('/sync-clocks').catch(() => null),
fetchWithAuth('/home-assistant/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)
let dynamicHtml = '';
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>`;
} else {
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
if (syncClocks.length > 0) {
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)}">&#x2191;</button>
<button type="button" class="playlist-item-btn" data-action="playlist-item-down" title="${escapeHtml(downLabel)}" aria-label="${escapeHtml(downLabel)}">&#x2193;</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,810 @@
/**
* 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;
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();
_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
void unmountAutoCalibration();
_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): void {
if (!_state) return;
_state.displayIndex = index;
_state.displayName = displayName;
_state.errorMsg = '';
_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);
}
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)">
</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
@@ -171,6 +171,8 @@ class TargetEditorModal extends Modal {
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
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() : []),
};
}
@@ -181,8 +183,13 @@ const targetEditorModal = new TargetEditorModal();
function _protocolBadge(device: any, target: any) {
const dt = device?.device_type;
if (!dt || dt === 'wled') {
const proto = target.protocol === 'http' ? 'HTTP' : 'DDP';
return `${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${proto}`;
const wledMap: Record<string, [string, string]> = {
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 = {
openrgb: [ICON_PALETTE, 'OpenRGB SDK'],
@@ -311,10 +318,11 @@ function _ensureProtocolIconSelect() {
if (!sel) return;
const items = [
{ 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') },
];
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 {
@@ -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-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 || '');
_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-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 || '');
_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-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('');
_ensureBrightnessWidget().setValue(1.0);
@@ -515,6 +529,8 @@ export async function saveTargetEditor() {
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
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 = {
name,
@@ -526,6 +542,8 @@ export async function saveTargetEditor() {
keepalive_interval: standbyInterval,
adaptive_fps: adaptiveFps,
protocol,
max_milliamps: maxMilliamps,
milliamps_per_led: milliampsPerLed,
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
};
@@ -44,7 +44,19 @@ const calibrationTutorialSteps: TutorialStep[] = [
{ 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[] = [
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
+33
View File
@@ -60,6 +60,21 @@ interface Window {
selectDisplay: (...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 ───
startCalibrationTutorial: (...args: any[]) => any;
startDeviceTutorial: (...args: any[]) => any;
@@ -354,6 +369,24 @@ startTargetOverlay: (...args: any[]) => any;
toggleTestEdge: (...args: any[]) => any;
showCSSCalibration: (...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 ───
showAdvancedCalibration: (...args: any[]) => any;
+6
View File
@@ -108,6 +108,12 @@ export type {
ScenePreset,
ScenePresetListResponse,
} from './types/scene-preset.ts';
export type {
PlaylistItem,
ScenePlaylist,
PlaylistRuntimeState,
ScenePlaylistListResponse,
} from './types/scene-playlist.ts';
// ── Sync Clock ────────────────────────────────────────────────
export type { SyncClock, SyncClockListResponse } from './types/sync-clock.ts';
@@ -50,6 +50,8 @@ export interface LedOutputTarget extends OutputTargetBase {
min_brightness_threshold?: BindableFloat;
adaptive_fps: boolean;
protocol: string;
max_milliamps?: number;
milliamps_per_led?: number;
}
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;
icon?: string;
icon_color?: string;
is_builtin?: boolean;
created_at: string;
updated_at: string;
}
+178 -9
View File
@@ -638,6 +638,12 @@
"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.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.button.cancel": "Cancel",
"calibration.button.save": "Save",
@@ -668,6 +674,7 @@
"common.none_own_speed": "None (no sync)",
"common.undo": "Undo",
"common.cancel": "Cancel",
"common.back": "Back",
"common.apply": "Apply",
"common.start": "START",
"common.stop": "STOP",
@@ -784,6 +791,7 @@
"device.icon.entity.ha_source": "Home Assistant source",
"device.icon.entity.automation": "Automation",
"device.icon.entity.scene_preset": "Scene preset",
"device.icon.entity.scene_playlist": "Playlist",
"device.icon.entity.sync_clock": "Sync clock",
"device.icon.entity.game_integration": "Game integration",
"device.icon.entity.audio_processing_template": "Audio processing template",
@@ -1101,6 +1109,7 @@
"dashboard.failed": "Failed to load dashboard",
"dashboard.section.automations": "Automations",
"dashboard.section.scenes": "Scene Presets",
"dashboard.section.playlists": "Playlists",
"dashboard.section.sync_clocks": "Sync Clocks",
"dashboard.targets": "Targets",
"dashboard.section.performance": "System Performance",
@@ -1235,6 +1244,17 @@
"automations.rule.time_of_day.start_time": "Start Time:",
"automations.rule.time_of_day.end_time": "End Time:",
"automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:0006: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.desc": "User idle/active",
"automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):",
@@ -1334,6 +1354,50 @@
"scenes.error.delete_failed": "Failed to delete scene",
"scenes.cloned": "Scene cloned",
"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.kc": "Key Colors",
"aria.close": "Close",
@@ -2079,8 +2143,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.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.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.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.desc": "JSON API — slower, ≤500 LEDs",
"targets.protocol.serial": "Serial",
@@ -2524,9 +2594,9 @@
"automations.rule.home_assistant.state": "State:",
"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.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.regex.desc": "State must match the regex pattern",
"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.regex.desc": "State must match the regex pattern",
"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.",
"graph.title": "Graph",
@@ -2667,6 +2737,7 @@
"section.empty.cspt": "No CSS processing templates 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.playlists": "No playlists yet. Click + to add one.",
"bulk.select": "Select",
"bulk.cancel": "Cancel",
"bulk.selected_count.one": "{count} selected",
@@ -2877,7 +2948,6 @@
"donation.about_donate": "Support development",
"donation.about_license": "MIT License",
"donation.about_author": "Created by",
"streams.group.game": "Game Integration",
"tree.group.game": "Game",
"game_integration.section_title": "Game Integrations",
@@ -2936,7 +3006,6 @@
"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.save_first": "Save the integration first before running auto setup",
"color_strip.type.game_event": "Game Event",
"color_strip.type.game_event.desc": "LED effects triggered by game events",
"color_strip.game_event.integration": "Game Integration:",
@@ -2946,7 +3015,6 @@
"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.error.no_integration": "Please select a game integration.",
"color_strip.type.math_wave": "Math Wave",
"color_strip.type.math_wave.desc": "Mathematical wave generator with gradient color mapping",
"color_strip.math_wave.gradient": "Color Gradient:",
@@ -2966,7 +3034,6 @@
"color_strip.math_wave.phase": "Phase",
"color_strip.math_wave.offset": "Offset",
"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.desc": "Game metrics (health, ammo, mana) as 0-1 values",
"value_source.game_event.integration": "Game Integration:",
@@ -2983,7 +3050,6 @@
"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.hint": "Seconds of silence before reverting to the default value.",
"audio_processing.title": "Audio Processing Templates",
"audio_processing.add": "Add Audio Processing Template",
"audio_processing.edit": "Edit Audio Processing Template",
@@ -3135,5 +3201,108 @@
"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.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."
}
+175 -6
View File
@@ -695,6 +695,12 @@
"calibration.skip_end": "Пропуск LED (конец):",
"calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)",
"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.button.cancel": "Отмена",
"calibration.button.save": "Сохранить",
@@ -725,6 +731,7 @@
"common.none_own_speed": "Нет (своя скорость)",
"common.undo": "Отменить",
"common.cancel": "Отмена",
"common.back": "Назад",
"common.apply": "Применить",
"common.start": "ПУСК",
"common.stop": "СТОП",
@@ -841,6 +848,7 @@
"device.icon.entity.ha_source": "Источник Home Assistant",
"device.icon.entity.automation": "Автоматизация",
"device.icon.entity.scene_preset": "Сцена",
"device.icon.entity.scene_playlist": "Плейлист",
"device.icon.entity.sync_clock": "Часы синхронизации",
"device.icon.entity.game_integration": "Игровая интеграция",
"device.icon.entity.audio_processing_template": "Шаблон обработки аудио",
@@ -1138,6 +1146,7 @@
"dashboard.failed": "Не удалось загрузить обзор",
"dashboard.section.automations": "Автоматизации",
"dashboard.section.scenes": "Пресеты сцен",
"dashboard.section.playlists": "Плейлисты",
"dashboard.section.sync_clocks": "Синхронные часы",
"dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы",
@@ -1269,6 +1278,17 @@
"automations.rule.time_of_day.start_time": "Время начала:",
"automations.rule.time_of_day.end_time": "Время окончания:",
"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.desc": "Бездействие/активность",
"automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
@@ -1368,6 +1388,50 @@
"scenes.error.delete_failed": "Не удалось удалить сцену",
"scenes.cloned": "Сцена клонирована",
"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.kc": "Цвета клавиш",
"aria.close": "Закрыть",
@@ -1939,8 +2003,14 @@
"targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.",
"targets.protocol": "Протокол:",
"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.desc": "Быстрые UDP-пакеты — рекомендуется",
"targets.protocol.udp": "WLED UDP (realtime)",
"targets.protocol.udp.desc": "Нативный realtime WLED — корректный RGBW и авто-возврат при обрыве потока",
"targets.protocol.http": "HTTP",
"targets.protocol.http.desc": "JSON API — медленнее, ≤500 LED",
"targets.protocol.serial": "Serial",
@@ -2348,6 +2418,7 @@
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.",
"section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.",
"section.empty.playlists": "Плейлистов пока нет. Нажмите + для добавления.",
"bulk.select": "Выбрать",
"bulk.cancel": "Отмена",
"bulk.selected_count.one": "{count} выбран",
@@ -2559,7 +2630,6 @@
"donation.about_donate": "Поддержать разработку",
"donation.about_license": "Лицензия MIT",
"donation.about_author": "Создатель —",
"streams.group.game": "Игровая интеграция",
"tree.group.game": "Игры",
"game_integration.section_title": "Игровые интеграции",
@@ -2618,7 +2688,6 @@
"game_integration.auto_setup.game_not_found": "Установка игры не найдена",
"game_integration.auto_setup.token_generated": "Токен авторизации был сгенерирован автоматически",
"game_integration.auto_setup.save_first": "Сначала сохраните интеграцию перед запуском автонастройки",
"color_strip.type.game_event": "Игровое событие",
"color_strip.type.game_event.desc": "LED-эффекты по игровым событиям",
"color_strip.game_event.integration": "Игровая интеграция:",
@@ -2628,7 +2697,6 @@
"color_strip.game_event.event_mappings": "Привязка событий:",
"color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.",
"color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.",
"color_strip.type.math_wave": "Математическая волна",
"color_strip.type.math_wave.desc": "Генератор математических волн с цветовым градиентом",
"color_strip.math_wave.gradient": "Цветовой градиент:",
@@ -2648,7 +2716,6 @@
"color_strip.math_wave.phase": "Фаза",
"color_strip.math_wave.offset": "Смещение",
"color_strip.math_wave.error.no_waves": "Добавьте хотя бы один слой волны.",
"value_source.type.game_event": "Игровое событие",
"value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1",
"value_source.game_event.integration": "Игровая интеграция:",
@@ -2665,7 +2732,6 @@
"value_source.game_event.default_value.hint": "Выходное значение, когда события не поступают в пределах таймаута.",
"value_source.game_event.timeout": "Таймаут (с):",
"value_source.game_event.timeout.hint": "Секунды тишины до возврата к значению по умолчанию.",
"audio_processing.title": "Шаблоны обработки звука",
"audio_processing.add": "Добавить шаблон обработки звука",
"audio_processing.edit": "Редактировать шаблон обработки звука",
@@ -2817,5 +2883,108 @@
"automations.rule.http_poll.operator.lt": "Меньше",
"automations.rule.http_poll.operator.lt.desc": "Числовое сравнение (<) — нужно числовое значение.",
"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-вывод."
}
+175 -6
View File
@@ -691,6 +691,12 @@
"calibration.skip_end": "跳过 LED(末尾):",
"calibration.skip_end.hint": "灯带末尾端关闭的 LED 数量(0 = 无)",
"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.button.cancel": "取消",
"calibration.button.save": "保存",
@@ -721,6 +727,7 @@
"common.none_own_speed": "无(使用自身速度)",
"common.undo": "撤销",
"common.cancel": "取消",
"common.back": "返回",
"common.apply": "应用",
"common.start": "启动",
"common.stop": "停止",
@@ -837,6 +844,7 @@
"device.icon.entity.ha_source": "Home Assistant 源",
"device.icon.entity.automation": "自动化",
"device.icon.entity.scene_preset": "场景预设",
"device.icon.entity.scene_playlist": "播放列表",
"device.icon.entity.sync_clock": "同步时钟",
"device.icon.entity.game_integration": "游戏集成",
"device.icon.entity.audio_processing_template": "音频处理模板",
@@ -1134,6 +1142,7 @@
"dashboard.failed": "加载仪表盘失败",
"dashboard.section.automations": "自动化",
"dashboard.section.scenes": "场景预设",
"dashboard.section.playlists": "播放列表",
"dashboard.section.sync_clocks": "同步时钟",
"dashboard.targets": "目标",
"dashboard.section.performance": "系统性能",
@@ -1265,6 +1274,17 @@
"automations.rule.time_of_day.start_time": "开始时间:",
"automations.rule.time_of_day.end_time": "结束时间:",
"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.desc": "空闲/活跃",
"automations.rule.system_idle.idle_minutes": "空闲超时(分钟):",
@@ -1364,6 +1384,50 @@
"scenes.error.delete_failed": "删除场景失败",
"scenes.cloned": "场景已克隆",
"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.kc": "关键颜色",
"aria.close": "关闭",
@@ -1935,8 +1999,14 @@
"targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。",
"targets.protocol": "协议:",
"targets.protocol.hint": "DDP通过快速UDP发送像素(推荐)。HTTP使用JSON API——较慢但可靠,限制约500个LED。",
"targets.power_limit": "最大电流 (ABL)",
"targets.power_limit.hint": "将灯带的估算电流限制在电源预算内,以防止明亮/白色场景下的电压骤降(颜色偏移、闪烁、重启)。请设为电源的额定电流并留有余量。0 = 不限制。",
"targets.power_limit.ma_suffix": "mA0 = 不限制)",
"targets.power_limit.per_led": "每颗 LED 电流(全白):",
"targets.protocol.ddp": "DDP (UDP)",
"targets.protocol.ddp.desc": "快速UDP数据包 - 推荐",
"targets.protocol.udp": "WLED UDP(实时)",
"targets.protocol.udp.desc": "WLED 原生实时 — 正确的 RGBW 白色,断流时自动恢复",
"targets.protocol.http": "HTTP",
"targets.protocol.http.desc": "JSON API - 较慢,≤500 LED",
"targets.protocol.serial": "串口",
@@ -2344,6 +2414,7 @@
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
"section.empty.automations": "暂无自动化。点击 + 添加。",
"section.empty.scenes": "暂无场景预设。点击 + 添加。",
"section.empty.playlists": "暂无播放列表。点击 + 添加。",
"bulk.select": "选择",
"bulk.cancel": "取消",
"bulk.selected_count.one": "已选 {count} 项",
@@ -2553,7 +2624,6 @@
"donation.about_donate": "支持开发",
"donation.about_license": "MIT 许可证",
"donation.about_author": "作者:",
"streams.group.game": "游戏集成",
"tree.group.game": "游戏",
"game_integration.section_title": "游戏集成",
@@ -2612,7 +2682,6 @@
"game_integration.auto_setup.game_not_found": "未找到游戏安装",
"game_integration.auto_setup.token_generated": "授权令牌已自动生成",
"game_integration.auto_setup.save_first": "请先保存集成,然后再运行自动配置",
"color_strip.type.game_event": "游戏事件",
"color_strip.type.game_event.desc": "由游戏事件触发的LED效果",
"color_strip.game_event.integration": "游戏集成:",
@@ -2622,7 +2691,6 @@
"color_strip.game_event.event_mappings": "事件映射:",
"color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。",
"color_strip.game_event.error.no_integration": "请选择游戏集成。",
"color_strip.type.math_wave": "数学波",
"color_strip.type.math_wave.desc": "使用渐变色映射的数学波形生成器",
"color_strip.math_wave.gradient": "颜色渐变:",
@@ -2642,7 +2710,6 @@
"color_strip.math_wave.phase": "相位",
"color_strip.math_wave.offset": "偏移",
"color_strip.math_wave.error.no_waves": "请至少添加一个波形层。",
"value_source.type.game_event": "游戏事件",
"value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值",
"value_source.game_event.integration": "游戏集成:",
@@ -2659,7 +2726,6 @@
"value_source.game_event.default_value.hint": "在超时时间内未收到事件时的输出值。",
"value_source.game_event.timeout": "超时(秒):",
"value_source.game_event.timeout.hint": "恢复到默认值前的静默秒数。",
"audio_processing.title": "音频处理模板",
"audio_processing.add": "添加音频处理模板",
"audio_processing.edit": "编辑音频处理模板",
@@ -2811,5 +2877,108 @@
"automations.rule.http_poll.operator.lt": "小于",
"automations.rule.http_poll.operator.lt.desc": "数值比较 (<) — 需要数值输出。",
"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 输出失败。"
}
+15 -2
View File
@@ -65,27 +65,40 @@ class ApplicationRule(Rule):
@dataclass
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
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"
start_time: str = "00:00" # 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:
d = super().to_dict()
d["start_time"] = self.start_time
d["end_time"] = self.end_time
d["days_of_week"] = self.days_of_week
d["timezone"] = self.timezone
return d
@classmethod
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(
start_time=data.get("start_time", "00:00"),
end_time=data.get("end_time", "23:59"),
days_of_week=days,
timezone=data.get("timezone", "") or "",
)
+1
View File
@@ -50,6 +50,7 @@ _ENTITY_TABLES = [
"value_sources",
"automations",
"scene_presets",
"scene_playlists",
"sync_clocks",
"color_strip_processing_templates",
"gradients",
@@ -95,6 +95,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold: Any = 0,
adaptive_fps: bool = False,
protocol: str = "ddp",
max_milliamps: int = 0,
milliamps_per_led: int = 55,
description: str | None = None,
tags: List[str] | None = None,
# legacy compat
@@ -116,6 +118,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0),
adaptive_fps=adaptive_fps,
protocol=protocol,
max_milliamps=max(0, int(max_milliamps or 0)),
milliamps_per_led=max(1, int(milliamps_per_led or 55)),
description=description,
created_at=now,
updated_at=now,
@@ -335,6 +339,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold: Any = None,
adaptive_fps: bool | None = None,
protocol: str | None = None,
max_milliamps: int | None = None,
milliamps_per_led: int | None = None,
description: str | None = None,
tags: List[str] | None = None,
icon: str | None = None,
@@ -356,6 +362,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
protocol=protocol,
max_milliamps=max_milliamps,
milliamps_per_led=milliamps_per_led,
description=description,
tags=tags,
icon=icon,
@@ -20,6 +20,7 @@ class PostprocessingTemplate:
tags: List[str] = field(default_factory=list)
icon: str = ""
icon_color: str = ""
is_builtin: bool = False
def to_dict(self) -> dict:
"""Convert template to dictionary."""
@@ -31,6 +32,7 @@ class PostprocessingTemplate:
"updated_at": self.updated_at.isoformat(),
"description": self.description,
"tags": self.tags,
"is_builtin": self.is_builtin,
}
if self.icon:
d["icon"] = self.icon
@@ -61,4 +63,5 @@ class PostprocessingTemplate:
tags=data.get("tags", []),
icon=data.get("icon", "") 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__)
# 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]):
"""Storage for postprocessing templates.
@@ -29,11 +80,42 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
def __init__(self, db: Database):
super().__init__(db, PostprocessingTemplate.from_dict)
self._ensure_initial_template()
self._seed_missing_builtins()
# Backward-compatible aliases
get_all_templates = BaseSqliteStore.get_all
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:
"""Auto-create a default postprocessing template if none exist."""
@@ -114,6 +196,9 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
) -> PostprocessingTemplate:
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:
self._check_name_unique(name, exclude_id=template_id)
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))
adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive
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:
"""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,
adaptive_fps=self.adaptive_fps,
protocol=self.protocol,
max_milliamps=self.max_milliamps,
milliamps_per_led=self.milliamps_per_led,
)
def sync_with_manager(
@@ -59,6 +66,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
"state_check_interval": self.state_check_interval,
"min_brightness_threshold": self.min_brightness_threshold,
"adaptive_fps": self.adaptive_fps,
"max_milliamps": self.max_milliamps,
"milliamps_per_led": self.milliamps_per_led,
},
)
if css_changed:
@@ -81,6 +90,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
min_brightness_threshold=None,
adaptive_fps=None,
protocol=None,
max_milliamps=None,
milliamps_per_led=None,
description=None,
tags: List[str] | None = None,
icon: str | None = None,
@@ -122,6 +133,10 @@ class WledOutputTarget(OutputTarget, type_key="led"):
self.adaptive_fps = adaptive_fps
if protocol is not None:
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
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["adaptive_fps"] = self.adaptive_fps
d["protocol"] = self.protocol
d["max_milliamps"] = self.max_milliamps
d["milliamps_per_led"] = self.milliamps_per_led
return d
@classmethod
@@ -165,6 +182,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
),
adaptive_fps=data.get("adaptive_fps", False),
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"),
tags=data.get("tags", []),
icon=data.get("icon", ""),
+6
View File
@@ -75,6 +75,9 @@
<div class="header-toolbar">
<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>
<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">
<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>
@@ -222,8 +225,10 @@
<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/advanced-calibration.html' %}
{% include 'modals/auto-calibration.html' %}
{% include 'modals/device-settings.html' %}
{% include 'modals/icon-picker.html' %}
{% include 'modals/target-editor.html' %}
@@ -246,6 +251,7 @@
{% include 'modals/cspt-modal.html' %}
{% include 'modals/automation-editor.html' %}
{% include 'modals/scene-preset-editor.html' %}
{% include 'modals/scene-playlist-editor.html' %}
{% include 'modals/audio-source-editor.html' %}
{% include 'modals/test-audio-source.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">&#x2715;</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>
@@ -170,6 +170,31 @@
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div>
</div>
<div class="form-group" style="margin-top: 16px;">
<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: 12px;">
<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>
</section>
@@ -208,6 +233,13 @@
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeCalibrationModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</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">&#x2713;</button>
</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">&#x2715;</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">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="savePlaylist()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</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">&#x2715;</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>
<select id="target-editor-protocol">
<option value="ddp">DDP (UDP)</option>
<option value="udp">WLED UDP (realtime)</option>
<option value="http">HTTP</option>
</select>
</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>
<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 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>
</details>
</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
@@ -0,0 +1,296 @@
"""Tests for scene-playlist CRUD + cycling-control routes.
The PlaylistEngine is replaced with a lightweight fake so the route layer is
tested without driving the real asyncio cycling loop.
"""
from datetime import datetime, timezone
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from ledgrab.api import dependencies as deps
from ledgrab.api.routes.scene_playlists import router
from ledgrab.core.scenes.playlist_engine import PlaylistError
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
from ledgrab.storage.scene_preset import ScenePreset
from ledgrab.storage.scene_preset_store import ScenePresetStore
class FakePlaylistEngine:
"""Minimal stand-in matching the methods the routes call."""
def __init__(self, store):
self._store = store
self._running_id = None
self.start_calls = []
self.stop_calls = 0
def get_running_playlist_id(self):
return self._running_id
def get_state(self):
if self._running_id is None:
return {
"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,
}
return {
"is_running": True,
"playlist_id": self._running_id,
"playlist_name": "running",
"current_index": 0,
"item_count": 1,
"current_preset_id": "scene_a",
"started_at": datetime.now(timezone.utc).isoformat(),
"step_started_at": datetime.now(timezone.utc).isoformat(),
"step_duration": 30.0,
}
async def start_playlist(self, playlist_id):
self.start_calls.append(playlist_id)
pl = self._store.get_playlist(playlist_id)
if not pl.items:
raise PlaylistError("empty")
self._running_id = playlist_id
async def stop(self):
self.stop_calls += 1
self._running_id = None
async def stop_if_running(self, playlist_id):
if self._running_id == playlist_id:
self._running_id = None
@pytest.fixture
def _route_db(tmp_path):
from ledgrab.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def preset_store(_route_db) -> ScenePresetStore:
store = ScenePresetStore(_route_db)
now = datetime.now(timezone.utc)
for sid, name in [("scene_a", "A"), ("scene_b", "B")]:
store.create_preset(
ScenePreset(id=sid, name=name, targets=[], created_at=now, updated_at=now)
)
return store
@pytest.fixture
def playlist_store(_route_db) -> ScenePlaylistStore:
return ScenePlaylistStore(_route_db)
@pytest.fixture
def fake_engine(playlist_store):
return FakePlaylistEngine(playlist_store)
@pytest.fixture
def client(playlist_store, preset_store, fake_engine):
app = FastAPI()
app.include_router(router)
from ledgrab.api.auth import verify_api_key
app.dependency_overrides[verify_api_key] = lambda: "test-user"
app.dependency_overrides[deps.get_scene_playlist_store] = lambda: playlist_store
app.dependency_overrides[deps.get_scene_preset_store] = lambda: preset_store
app.dependency_overrides[deps.get_playlist_engine] = lambda: fake_engine
# Routes fire entity events through the processor manager; give it a stub.
deps._deps["processor_manager"] = None
return TestClient(app, raise_server_exceptions=False)
def _create(client, name="P1", items=None, **extra):
body = {"name": name, "items": items if items is not None else [], **extra}
return client.post("/api/v1/scene-playlists", json=body)
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
class TestCreate:
def test_create_minimal(self, client):
resp = _create(client, "Empty OK")
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Empty OK"
assert data["loop"] is True
assert data["is_running"] is False
assert data["id"].startswith("playlist_")
def test_create_with_items(self, client):
resp = _create(
client,
"With items",
items=[
{"scene_preset_id": "scene_a", "duration_seconds": 15},
{"scene_preset_id": "scene_b", "duration_seconds": 45},
],
loop=False,
shuffle=True,
)
assert resp.status_code == 201
data = resp.json()
assert len(data["items"]) == 2
assert data["loop"] is False
assert data["shuffle"] is True
def test_create_rejects_unknown_preset(self, client):
resp = _create(
client, "Bad ref", items=[{"scene_preset_id": "ghost", "duration_seconds": 10}]
)
assert resp.status_code == 400
assert "ghost" in resp.json()["detail"]
def test_create_rejects_below_min_duration(self, client):
resp = _create(
client, "Too fast", items=[{"scene_preset_id": "scene_a", "duration_seconds": 0}]
)
# Pydantic ge=MIN validation → 422
assert resp.status_code == 422
def test_create_duplicate_name(self, client):
_create(client, "Dup")
resp = _create(client, "Dup")
assert resp.status_code == 400
class TestList:
def test_list_empty_includes_idle_state(self, client):
resp = client.get("/api/v1/scene-playlists")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["state"]["is_running"] is False
def test_list_marks_running(self, client, fake_engine):
pid = _create(
client, "Runner", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}]
).json()["id"]
fake_engine._running_id = pid
data = client.get("/api/v1/scene-playlists").json()
assert data["count"] == 1
assert data["playlists"][0]["is_running"] is True
assert data["state"]["is_running"] is True
class TestGet:
def test_get_existing(self, client):
pid = _create(client, "Find me").json()["id"]
resp = client.get(f"/api/v1/scene-playlists/{pid}")
assert resp.status_code == 200
assert resp.json()["name"] == "Find me"
def test_get_missing(self, client):
resp = client.get("/api/v1/scene-playlists/nope")
assert resp.status_code == 404
def test_state_route_not_shadowed_by_id(self, client):
# The literal /state path must resolve to the state endpoint.
resp = client.get("/api/v1/scene-playlists/state")
assert resp.status_code == 200
assert "is_running" in resp.json()
class TestUpdate:
def test_update_fields(self, client):
pid = _create(client, "Edit").json()["id"]
resp = client.put(
f"/api/v1/scene-playlists/{pid}",
json={
"name": "Edited",
"loop": False,
"items": [{"scene_preset_id": "scene_b", "duration_seconds": 20}],
},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Edited"
assert data["loop"] is False
assert data["items"][0]["scene_preset_id"] == "scene_b"
def test_update_rejects_unknown_preset(self, client):
pid = _create(client, "Edit2").json()["id"]
resp = client.put(
f"/api/v1/scene-playlists/{pid}",
json={"items": [{"scene_preset_id": "ghost", "duration_seconds": 10}]},
)
assert resp.status_code == 400
def test_update_missing(self, client):
resp = client.put("/api/v1/scene-playlists/nope", json={"name": "x"})
assert resp.status_code == 404
class TestDelete:
def test_delete(self, client):
pid = _create(client, "Goner").json()["id"]
resp = client.delete(f"/api/v1/scene-playlists/{pid}")
assert resp.status_code == 204
assert client.get(f"/api/v1/scene-playlists/{pid}").status_code == 404
def test_delete_stops_if_running(self, client, fake_engine):
pid = _create(
client, "Running goner", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}]
).json()["id"]
fake_engine._running_id = pid
client.delete(f"/api/v1/scene-playlists/{pid}")
assert fake_engine.get_running_playlist_id() is None
def test_delete_missing(self, client):
assert client.delete("/api/v1/scene-playlists/nope").status_code == 404
# ---------------------------------------------------------------------------
# Cycling control
# ---------------------------------------------------------------------------
class TestControl:
def test_start(self, client, fake_engine):
pid = _create(
client, "Go", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}]
).json()["id"]
resp = client.post(f"/api/v1/scene-playlists/{pid}/start")
assert resp.status_code == 200
assert resp.json()["is_running"] is True
assert fake_engine.start_calls == [pid]
def test_start_missing_playlist(self, client):
resp = client.post("/api/v1/scene-playlists/nope/start")
assert resp.status_code == 404
def test_start_empty_playlist_400(self, client):
pid = _create(client, "EmptyGo").json()["id"]
resp = client.post(f"/api/v1/scene-playlists/{pid}/start")
assert resp.status_code == 400
def test_stop(self, client, fake_engine):
pid = _create(
client, "Stoppable", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}]
).json()["id"]
fake_engine._running_id = pid
resp = client.post("/api/v1/scene-playlists/stop")
assert resp.status_code == 200
assert resp.json()["is_running"] is False
assert fake_engine.stop_calls == 1
@@ -0,0 +1,600 @@
"""Tests for the setup scaffold endpoint and the onboarding preference endpoints.
Coverage:
- scaffold happy path (device_id-based; 4 entities created, correct linking ids,
entity events fired ONLY after full success)
- scaffold reuses existing capture template
- scaffold partial-failure rollback (force a later step to fail no orphans AND
no stray "created" events emitted for the rolled-back entities)
- scaffold 404 for unknown/missing device_id
- scaffold 422 for display_index out of range (> 63)
- scaffold 422 when device_id field is absent (Pydantic validation)
- onboarding GET default (onboarded=false, completed_at=null)
- onboarding PUT round-trip (timestamps auto-stamped)
- integration: scaffold PUT calibration on the CSS GET CSS round-trips with it
Deep adversarial coverage is deferred to the Phase 4 test-writer (Big Bang strategy).
"""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_db(tmp_path):
from ledgrab.storage.database import Database
db = Database(tmp_path / "test_setup.db")
yield db
db.close()
@pytest.fixture
def device_store(tmp_db):
from ledgrab.storage import DeviceStore
return DeviceStore(tmp_db)
@pytest.fixture
def template_store(tmp_db):
from ledgrab.storage.template_store import TemplateStore
return TemplateStore(tmp_db)
@pytest.fixture
def picture_source_store(tmp_db):
from ledgrab.storage.picture_source_store import PictureSourceStore
return PictureSourceStore(tmp_db)
@pytest.fixture
def css_store(tmp_db):
from ledgrab.storage.color_strip_store import ColorStripStore
return ColorStripStore(tmp_db)
@pytest.fixture
def output_target_store(tmp_db):
from ledgrab.storage.output_target_store import OutputTargetStore
return OutputTargetStore(tmp_db)
@pytest.fixture
def sample_device(device_store):
return device_store.create_device(
name="Test LED Strip",
url="http://192.168.1.10",
led_count=60,
)
@pytest.fixture
def event_log():
"""Collect fire_entity_event calls for assertion."""
log = []
return log
@pytest.fixture
def mock_manager():
"""A MagicMock ProcessorManager that silently accepts all calls."""
mgr = MagicMock()
mgr.remove_target = MagicMock()
return mgr
@pytest.fixture
def setup_client(
tmp_db,
device_store,
template_store,
picture_source_store,
css_store,
output_target_store,
event_log,
mock_manager,
):
from ledgrab.api.routes.setup import router
from ledgrab.api.auth import verify_api_key
from ledgrab.api import dependencies as deps
app = FastAPI()
app.include_router(router)
app.dependency_overrides[verify_api_key] = lambda: "test"
app.dependency_overrides[deps.get_device_store] = lambda: device_store
app.dependency_overrides[deps.get_template_store] = lambda: template_store
app.dependency_overrides[deps.get_picture_source_store] = lambda: picture_source_store
app.dependency_overrides[deps.get_color_strip_store] = lambda: css_store
app.dependency_overrides[deps.get_output_target_store] = lambda: output_target_store
app.dependency_overrides[deps.get_processor_manager] = lambda: mock_manager
# Capture entity events
def _fire(entity_type, action, entity_id):
event_log.append((entity_type, action, entity_id))
with patch("ledgrab.api.routes.setup.fire_entity_event", side_effect=_fire):
yield TestClient(app, raise_server_exceptions=False)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _scaffold(client, **overrides):
"""POST a scaffold request with sensible defaults."""
body = {"display_index": 0, **overrides}
return client.post("/api/v1/setup/scaffold", json=body)
# ---------------------------------------------------------------------------
# Scaffold: happy path (using existing device)
# ---------------------------------------------------------------------------
class TestScaffoldHappyPath:
def test_returns_201(self, setup_client, sample_device, template_store):
resp = _scaffold(setup_client, device_id=sample_device.id)
assert resp.status_code == 201, resp.text
def test_response_contains_all_ids(self, setup_client, sample_device):
resp = _scaffold(setup_client, device_id=sample_device.id)
data = resp.json()
assert data["device_id"] == sample_device.id
assert data["capture_template_id"].startswith("tpl_")
assert data["picture_source_id"].startswith("ps_")
assert data["color_strip_source_id"].startswith("css_")
assert data["output_target_id"].startswith("pt_")
def test_response_has_no_device_created_field(self, setup_client, sample_device):
"""ScaffoldResponse no longer includes device_created — devices are always pre-existing."""
resp = _scaffold(setup_client, device_id=sample_device.id)
assert "device_created" not in resp.json()
def test_entities_are_persisted(
self,
setup_client,
sample_device,
picture_source_store,
css_store,
output_target_store,
):
resp = _scaffold(setup_client, device_id=sample_device.id)
data = resp.json()
# All entities retrievable from the stores
ps = picture_source_store.get(data["picture_source_id"])
assert ps is not None
css = css_store.get_source(data["color_strip_source_id"])
assert css is not None
ot = output_target_store.get(data["output_target_id"])
assert ot is not None
def test_entity_links_are_correct(
self,
setup_client,
sample_device,
picture_source_store,
css_store,
output_target_store,
):
resp = _scaffold(setup_client, device_id=sample_device.id)
data = resp.json()
ps = picture_source_store.get(data["picture_source_id"])
assert ps.capture_template_id == data["capture_template_id"]
assert ps.display_index == 0
css = css_store.get_source(data["color_strip_source_id"])
assert css.picture_source_id == data["picture_source_id"]
ot = output_target_store.get(data["output_target_id"])
assert ot.device_id == sample_device.id
assert ot.color_strip_source_id == data["color_strip_source_id"]
def test_entity_events_fire_after_success(self, setup_client, sample_device, event_log):
"""Events must be emitted for all created entities — and only after success."""
_scaffold(setup_client, device_id=sample_device.id)
types_fired = {(et, act) for et, act, _ in event_log}
assert ("picture_source", "created") in types_fired
assert ("color_strip_source", "created") in types_fired
assert ("output_target", "created") in types_fired
# Device is pre-existing — no device "created" event expected
assert ("device", "created") not in types_fired
def test_events_fired_only_once_per_entity(self, setup_client, sample_device, event_log):
"""No duplicate events for a single scaffold call."""
_scaffold(setup_client, device_id=sample_device.id)
ps_created = [(et, act, eid) for et, act, eid in event_log if et == "picture_source"]
css_created = [(et, act, eid) for et, act, eid in event_log if et == "color_strip_source"]
ot_created = [(et, act, eid) for et, act, eid in event_log if et == "output_target"]
assert len(ps_created) == 1
assert len(css_created) == 1
assert len(ot_created) == 1
# ---------------------------------------------------------------------------
# Scaffold: reuse existing capture template
# ---------------------------------------------------------------------------
class TestScaffoldReusesTemplate:
def test_reuse_existing_template(self, setup_client, sample_device, template_store):
"""TemplateStore auto-creates a 'Default' template; the scaffold must reuse it."""
all_templates_before = template_store.get_all_templates()
assert len(all_templates_before) >= 1, "TemplateStore should auto-create one"
resp = _scaffold(setup_client, device_id=sample_device.id)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["capture_template_reused"] is True
# No new templates created
all_templates_after = template_store.get_all_templates()
assert len(all_templates_after) == len(all_templates_before)
# ---------------------------------------------------------------------------
# Scaffold: validation errors
# ---------------------------------------------------------------------------
class TestScaffoldValidation:
def test_unknown_device_id_returns_404(self, setup_client):
resp = _scaffold(setup_client, device_id="device_doesnotexist")
assert resp.status_code == 404, resp.text
def test_missing_device_id_returns_422(self, setup_client):
"""device_id is now required — omitting it must yield 422."""
resp = setup_client.post("/api/v1/setup/scaffold", json={"display_index": 0})
assert resp.status_code == 422, resp.text
def test_display_index_above_max_returns_422(self, setup_client, sample_device):
"""display_index > 63 must be rejected with 422."""
resp = _scaffold(setup_client, device_id=sample_device.id, display_index=64)
assert resp.status_code == 422, resp.text
def test_display_index_at_max_accepted(self, setup_client, sample_device):
"""display_index == 63 is at the upper bound and must be accepted."""
resp = _scaffold(setup_client, device_id=sample_device.id, display_index=63)
assert resp.status_code == 201, resp.text
def test_display_index_negative_returns_422(self, setup_client, sample_device):
resp = _scaffold(setup_client, device_id=sample_device.id, display_index=-1)
assert resp.status_code == 422, resp.text
def test_custom_display_index_stored(self, setup_client, sample_device, picture_source_store):
resp = _scaffold(setup_client, device_id=sample_device.id, display_index=2)
assert resp.status_code == 201, resp.text
ps = picture_source_store.get(resp.json()["picture_source_id"])
assert ps.display_index == 2
# ---------------------------------------------------------------------------
# Scaffold: rollback on partial failure — no orphans AND no ghost events
# ---------------------------------------------------------------------------
class TestScaffoldRollback:
def test_no_orphans_when_css_creation_fails(
self,
setup_client,
sample_device,
picture_source_store,
css_store,
):
"""Force css_store.create_source to raise; expect the picture source to be deleted too."""
original_create = css_store.create_source
def _fail_create(*args, **kwargs):
raise ValueError("Simulated CSS creation failure")
css_store.create_source = _fail_create
try:
resp = _scaffold(setup_client, device_id=sample_device.id)
assert resp.status_code == 400, resp.text
# No picture sources should remain
remaining_ps = picture_source_store.get_all_streams()
assert len(remaining_ps) == 0, "Picture source should have been rolled back"
# No CSS created either
remaining_css = css_store.get_all_sources()
assert len(remaining_css) == 0
finally:
css_store.create_source = original_create
def test_no_orphans_when_output_target_creation_fails(
self,
setup_client,
sample_device,
picture_source_store,
css_store,
output_target_store,
):
"""Force output_target_store.create_wled_target to raise; picture source and CSS rolled back."""
original_create = output_target_store.create_wled_target
def _fail_create(*args, **kwargs):
raise ValueError("Simulated output target failure")
output_target_store.create_wled_target = _fail_create
try:
resp = _scaffold(setup_client, device_id=sample_device.id)
assert resp.status_code == 400, resp.text
assert len(picture_source_store.get_all_streams()) == 0
assert len(css_store.get_all_sources()) == 0
assert len(output_target_store.get_all_targets()) == 0
finally:
output_target_store.create_wled_target = original_create
def test_no_created_events_emitted_on_rollback(
self,
setup_client,
sample_device,
css_store,
event_log,
):
"""On failure no 'created' events must leak (deferred-event contract)."""
original_create = css_store.create_source
def _fail_create(*args, **kwargs):
raise ValueError("Simulated CSS failure")
css_store.create_source = _fail_create
try:
resp = _scaffold(setup_client, device_id=sample_device.id)
assert resp.status_code == 400, resp.text
created_events = [(et, act) for et, act, _ in event_log if act == "created"]
assert (
created_events == []
), f"No 'created' events should fire on rollback, got: {created_events}"
finally:
css_store.create_source = original_create
def test_reused_template_not_deleted_on_rollback(
self,
setup_client,
sample_device,
template_store,
css_store,
):
"""A reused (pre-existing) capture template must survive rollback."""
templates_before = {t.id for t in template_store.get_all_templates()}
original_create_css = css_store.create_source
def _fail_css(*args, **kwargs):
raise ValueError("Forced failure")
css_store.create_source = _fail_css
try:
_scaffold(setup_client, device_id=sample_device.id)
finally:
css_store.create_source = original_create_css
templates_after = {t.id for t in template_store.get_all_templates()}
assert templates_before == templates_after
def test_device_never_deleted_on_rollback(
self,
setup_client,
sample_device,
device_store,
css_store,
):
"""The pre-existing device must never be touched by rollback."""
original_create = css_store.create_source
def _fail_create(*args, **kwargs):
raise ValueError("Simulated CSS failure")
css_store.create_source = _fail_create
try:
_scaffold(setup_client, device_id=sample_device.id)
finally:
css_store.create_source = original_create
# Device must still exist
device = device_store.get(sample_device.id)
assert device is not None
assert device.name == sample_device.name
# ---------------------------------------------------------------------------
# Onboarding preference
# ---------------------------------------------------------------------------
@pytest.fixture
def pref_client(tmp_db):
from ledgrab.api.routes.preferences import router
from ledgrab.api.auth import verify_api_key
from ledgrab.api import dependencies as deps
app = FastAPI()
app.include_router(router)
app.dependency_overrides[verify_api_key] = lambda: "test"
app.dependency_overrides[deps.get_database] = lambda: tmp_db
return TestClient(app)
class TestOnboarding:
def test_get_default_returns_not_onboarded(self, pref_client):
resp = pref_client.get("/api/v1/preferences/onboarding")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["onboarded"] is False
assert data["completed_at"] is None
def test_put_onboarded_true_round_trips(self, pref_client):
resp = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["onboarded"] is True
# Server auto-stamps completed_at
assert data["completed_at"] is not None
def test_get_after_put_reflects_stored_value(self, pref_client):
pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True})
resp = pref_client.get("/api/v1/preferences/onboarding")
assert resp.status_code == 200
assert resp.json()["onboarded"] is True
def test_put_false_clears_completed_at(self, pref_client):
# First set to true
pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True})
# Then reset
resp = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": False})
assert resp.status_code == 200
data = resp.json()
assert data["onboarded"] is False
assert data["completed_at"] is None
def test_put_with_explicit_completed_at_preserved(self, pref_client):
ts = "2026-01-01T00:00:00+00:00"
resp = pref_client.put(
"/api/v1/preferences/onboarding",
json={"onboarded": True, "completed_at": ts},
)
assert resp.status_code == 200
assert resp.json()["completed_at"] == ts
# ---------------------------------------------------------------------------
# Integration: scaffold → PUT calibration onto CSS → GET CSS round-trips
# ---------------------------------------------------------------------------
class TestScaffoldCalibrationIntegration:
def test_scaffold_then_update_css_calibration(
self,
setup_client,
sample_device,
css_store,
):
"""Full integration path: scaffold → apply solved calibration via CSS PUT.
Uses the same css_store fixture that the setup_client uses, so the
scaffolded entity is visible to it after creation.
"""
from ledgrab.core.capture.calibration import CalibrationConfig
# Step 1: scaffold
resp = _scaffold(setup_client, device_id=sample_device.id)
assert resp.status_code == 201, resp.text
css_id = resp.json()["color_strip_source_id"]
# Confirm entity exists in the shared store
css = css_store.get_source(css_id)
assert css is not None
# Step 2: build a solved calibration (mimics Phase 1 solve output)
solved_cal = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
leds_top=15,
leds_right=9,
leds_bottom=15,
leds_left=9,
)
# Step 3: persist via store update (the real CSS PUT does this)
css_store.update_source(css_id, calibration=solved_cal)
# Step 4: assert the calibration round-trips
updated = css_store.get_source(css_id)
assert updated.calibration.leds_top == 15
assert updated.calibration.leds_right == 9
assert updated.calibration.layout == "clockwise"
# ---------------------------------------------------------------------------
# Regression: scaffold registers the output target with ProcessorManager
# ---------------------------------------------------------------------------
class TestScaffoldRegistersWithManager:
def test_scaffold_calls_add_target_on_manager(
self,
setup_client,
sample_device,
mock_manager,
):
"""Blocker regression: scaffold must register the created output target
with the ProcessorManager so that a subsequent start call can find it.
WledOutputTarget.register_with_manager calls manager.add_target(...)
we assert that method was invoked with the scaffolded target id.
"""
resp = _scaffold(setup_client, device_id=sample_device.id)
assert resp.status_code == 201, resp.text
target_id = resp.json()["output_target_id"]
# register_with_manager → manager.add_target(target_id=..., ...)
mock_manager.add_target.assert_called_once()
call_kwargs = mock_manager.add_target.call_args
assert call_kwargs.kwargs.get("target_id") == target_id, (
f"manager.add_target was not called with target_id={target_id!r}; "
f"actual call: {call_kwargs}"
)
def test_scaffold_rollback_calls_remove_target_on_manager(
self,
setup_client,
sample_device,
output_target_store,
mock_manager,
):
"""Rollback after a post-target-creation failure must call manager.remove_target
so no half-registered target lingers in the ProcessorManager.
We inject a failure by making register_with_manager raise RuntimeError
(bypassing the ValueError-only guard), which puts the outer except branch
in play. The target IS already in created_ids at that point, so rollback
must call manager.remove_target(target_id).
"""
created_target_ids: list[str] = []
original_create = output_target_store.create_wled_target
def _spy_create(*args, **kwargs):
target = original_create(*args, **kwargs)
created_target_ids.append(target.id)
return target
output_target_store.create_wled_target = _spy_create
try:
# Patch register_with_manager on WledOutputTarget to raise RuntimeError —
# RuntimeError bypasses the ValueError guard and triggers the outer except,
# so rollback fires with the target already in created_ids.
with patch(
"ledgrab.storage.wled_output_target.WledOutputTarget.register_with_manager",
side_effect=RuntimeError("Injected registration failure for rollback test"),
):
resp = setup_client.post(
"/api/v1/setup/scaffold",
json={"device_id": sample_device.id, "display_index": 0},
)
assert resp.status_code == 500, resp.text
assert len(created_target_ids) == 1, "spy did not record a created target"
target_id = created_target_ids[0]
mock_manager.remove_target.assert_called_with(target_id)
finally:
output_target_store.create_wled_target = original_create
@@ -0,0 +1,506 @@
"""Adversarial tests for the setup scaffold and onboarding preference endpoints.
Phase 2 acceptance criteria (NOT what the code happens to do):
- Rollback when the FINAL step (output target create) fails leaves ZERO orphans
AND emits ZERO "created" events.
- Reused capture template is NOT deleted on rollback.
- display_index > 63 422.
- Missing device_id 422 (Pydantic validation before handler runs).
- Unknown device_id 404.
- PUT onboarding false clears completed_at to null.
- Corrupt stored onboarding value falls back to default (onboarded=false).
These fill the gaps in the existing 22 happy-path tests.
"""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Shared fixtures (mirrors test_setup_routes.py exactly)
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_db(tmp_path):
from ledgrab.storage.database import Database
db = Database(tmp_path / "adv_setup.db")
yield db
db.close()
@pytest.fixture
def device_store(tmp_db):
from ledgrab.storage import DeviceStore
return DeviceStore(tmp_db)
@pytest.fixture
def template_store(tmp_db):
from ledgrab.storage.template_store import TemplateStore
return TemplateStore(tmp_db)
@pytest.fixture
def picture_source_store(tmp_db):
from ledgrab.storage.picture_source_store import PictureSourceStore
return PictureSourceStore(tmp_db)
@pytest.fixture
def css_store(tmp_db):
from ledgrab.storage.color_strip_store import ColorStripStore
return ColorStripStore(tmp_db)
@pytest.fixture
def output_target_store(tmp_db):
from ledgrab.storage.output_target_store import OutputTargetStore
return OutputTargetStore(tmp_db)
@pytest.fixture
def sample_device(device_store):
return device_store.create_device(
name="Test LED Strip",
url="http://192.168.1.10",
led_count=60,
)
@pytest.fixture
def event_log():
log = []
return log
@pytest.fixture
def mock_manager():
"""A MagicMock ProcessorManager that silently accepts all calls."""
mgr = MagicMock()
mgr.remove_target = MagicMock()
return mgr
@pytest.fixture
def setup_client(
tmp_db,
device_store,
template_store,
picture_source_store,
css_store,
output_target_store,
event_log,
mock_manager,
):
from ledgrab.api.routes.setup import router
from ledgrab.api.auth import verify_api_key
from ledgrab.api import dependencies as deps
app = FastAPI()
app.include_router(router)
app.dependency_overrides[verify_api_key] = lambda: "test"
app.dependency_overrides[deps.get_device_store] = lambda: device_store
app.dependency_overrides[deps.get_template_store] = lambda: template_store
app.dependency_overrides[deps.get_picture_source_store] = lambda: picture_source_store
app.dependency_overrides[deps.get_color_strip_store] = lambda: css_store
app.dependency_overrides[deps.get_output_target_store] = lambda: output_target_store
app.dependency_overrides[deps.get_processor_manager] = lambda: mock_manager
def _fire(entity_type, action, entity_id):
event_log.append((entity_type, action, entity_id))
with patch("ledgrab.api.routes.setup.fire_entity_event", side_effect=_fire):
yield TestClient(app, raise_server_exceptions=False)
def _scaffold(client, **overrides):
body = {"display_index": 0, **overrides}
return client.post("/api/v1/setup/scaffold", json=body)
# ---------------------------------------------------------------------------
# Rollback when the FINAL step (output target) fails
# Criteria: "zero orphans AND zero 'created' events"
# ---------------------------------------------------------------------------
class TestFinalStepRollback:
def test_final_step_failure_leaves_zero_orphans(
self,
setup_client,
sample_device,
picture_source_store,
css_store,
output_target_store,
):
"""output_target_store.create_wled_target failing leaves NO orphaned entities."""
original_create = output_target_store.create_wled_target
def _fail(*args, **kwargs):
raise ValueError("Injected final step failure")
output_target_store.create_wled_target = _fail
try:
resp = _scaffold(setup_client, device_id=sample_device.id)
assert resp.status_code in (
400,
500,
), f"Expected 4xx/5xx on final-step failure, got {resp.status_code}: {resp.text}"
# Zero picture sources remaining
remaining_ps = picture_source_store.get_all_streams()
assert len(remaining_ps) == 0, f"Picture sources not rolled back: {remaining_ps}"
# Zero color-strip sources remaining
remaining_css = css_store.get_all_sources()
assert len(remaining_css) == 0, f"Color-strip sources not rolled back: {remaining_css}"
# Zero output targets (trivially true since creation was never reached,
# but included for completeness)
remaining_ot = output_target_store.get_all_targets()
assert len(remaining_ot) == 0, f"Output targets not rolled back: {remaining_ot}"
finally:
output_target_store.create_wled_target = original_create
def test_final_step_failure_emits_zero_created_events(
self,
setup_client,
sample_device,
output_target_store,
event_log,
):
"""No 'created' events must be emitted when the final step fails (deferred-event contract)."""
original_create = output_target_store.create_wled_target
def _fail(*args, **kwargs):
raise ValueError("Final step injected failure")
output_target_store.create_wled_target = _fail
try:
resp = _scaffold(setup_client, device_id=sample_device.id)
assert resp.status_code in (400, 500)
created_events = [(et, act) for et, act, _ in event_log if act == "created"]
assert (
created_events == []
), f"'created' events leaked on final-step rollback: {created_events}"
finally:
output_target_store.create_wled_target = original_create
def test_final_step_failure_reused_template_survives(
self,
setup_client,
sample_device,
template_store,
output_target_store,
):
"""A reused capture template must NOT be deleted when the final step fails."""
templates_before = {t.id for t in template_store.get_all_templates()}
original_create = output_target_store.create_wled_target
def _fail(*args, **kwargs):
raise ValueError("Final step injected failure")
output_target_store.create_wled_target = _fail
try:
_scaffold(setup_client, device_id=sample_device.id)
finally:
output_target_store.create_wled_target = original_create
templates_after = {t.id for t in template_store.get_all_templates()}
assert templates_before == templates_after, (
f"Template set changed after rollback: "
f"before={templates_before} after={templates_after}"
)
def test_final_step_failure_device_not_deleted(
self,
setup_client,
sample_device,
device_store,
output_target_store,
):
"""The pre-existing device must never be touched by rollback of any step."""
original_create = output_target_store.create_wled_target
def _fail(*args, **kwargs):
raise ValueError("Final step injected failure")
output_target_store.create_wled_target = _fail
try:
_scaffold(setup_client, device_id=sample_device.id)
finally:
output_target_store.create_wled_target = original_create
device = device_store.get(sample_device.id)
assert device is not None, "Pre-existing device was deleted during rollback"
# ---------------------------------------------------------------------------
# Validation: display_index bounds
# Criteria: display_index > 63 → 422; display_index 63 → 201; negative → 422
# ---------------------------------------------------------------------------
class TestDisplayIndexBounds:
def test_display_index_64_returns_422(self, setup_client, sample_device):
"""display_index=64 (one above max) must be rejected with 422."""
resp = _scaffold(setup_client, device_id=sample_device.id, display_index=64)
assert (
resp.status_code == 422
), f"Expected 422 for display_index=64, got {resp.status_code}: {resp.text}"
def test_display_index_63_returns_201(self, setup_client, sample_device):
"""display_index=63 is the maximum valid value — must be accepted."""
resp = _scaffold(setup_client, device_id=sample_device.id, display_index=63)
assert (
resp.status_code == 201
), f"Expected 201 for display_index=63, got {resp.status_code}: {resp.text}"
def test_display_index_0_returns_201(self, setup_client, sample_device):
"""display_index=0 is the minimum valid value."""
resp = _scaffold(setup_client, device_id=sample_device.id, display_index=0)
assert (
resp.status_code == 201
), f"Expected 201 for display_index=0, got {resp.status_code}: {resp.text}"
def test_display_index_negative_1_returns_422(self, setup_client, sample_device):
"""display_index=-1 must be rejected with 422."""
resp = _scaffold(setup_client, device_id=sample_device.id, display_index=-1)
assert (
resp.status_code == 422
), f"Expected 422 for display_index=-1, got {resp.status_code}: {resp.text}"
def test_display_index_very_large_returns_422(self, setup_client, sample_device):
"""display_index=10000 must be rejected with 422."""
resp = _scaffold(setup_client, device_id=sample_device.id, display_index=10000)
assert (
resp.status_code == 422
), f"Expected 422 for display_index=10000, got {resp.status_code}: {resp.text}"
# ---------------------------------------------------------------------------
# Validation: missing / unknown device_id
# ---------------------------------------------------------------------------
class TestDeviceValidation:
def test_missing_device_id_returns_422(self, setup_client):
"""Omitting device_id entirely must yield 422 (Pydantic required field)."""
resp = setup_client.post("/api/v1/setup/scaffold", json={"display_index": 0})
assert (
resp.status_code == 422
), f"Expected 422 for missing device_id, got {resp.status_code}: {resp.text}"
def test_empty_string_device_id_handled(self, setup_client):
"""device_id='' — should yield 404 (empty string not in device store) or 422."""
resp = _scaffold(setup_client, device_id="")
assert resp.status_code in (
404,
422,
), f"Expected 404 or 422 for empty device_id, got {resp.status_code}: {resp.text}"
def test_unknown_device_id_returns_404(self, setup_client):
"""device_id that does not exist in the store must yield 404."""
resp = _scaffold(setup_client, device_id="device_definitely_does_not_exist")
assert (
resp.status_code == 404
), f"Expected 404 for unknown device_id, got {resp.status_code}: {resp.text}"
def test_none_device_id_returns_422(self, setup_client):
"""device_id=null must yield 422 (not None-convertible to str)."""
resp = setup_client.post(
"/api/v1/setup/scaffold", json={"device_id": None, "display_index": 0}
)
assert (
resp.status_code == 422
), f"Expected 422 for null device_id, got {resp.status_code}: {resp.text}"
# ---------------------------------------------------------------------------
# Rollback idempotency: calling scaffold twice with the final step failing
# must still leave exactly zero orphans (no accumulation across calls).
# ---------------------------------------------------------------------------
class TestRollbackIdempotency:
def test_two_failed_scaffolds_leave_zero_orphans(
self,
setup_client,
sample_device,
picture_source_store,
css_store,
output_target_store,
):
"""Two sequential scaffold failures must not accumulate orphans."""
original_create = output_target_store.create_wled_target
def _fail(*args, **kwargs):
raise ValueError("Always fails")
output_target_store.create_wled_target = _fail
try:
_scaffold(setup_client, device_id=sample_device.id)
_scaffold(setup_client, device_id=sample_device.id)
finally:
output_target_store.create_wled_target = original_create
assert (
len(picture_source_store.get_all_streams()) == 0
), "Picture sources accumulated across two failed scaffolds"
assert (
len(css_store.get_all_sources()) == 0
), "Color-strip sources accumulated across two failed scaffolds"
# ---------------------------------------------------------------------------
# Onboarding: adversarial cases
# ---------------------------------------------------------------------------
@pytest.fixture
def pref_client(tmp_db):
from ledgrab.api.routes.preferences import router
from ledgrab.api.auth import verify_api_key
from ledgrab.api import dependencies as deps
app = FastAPI()
app.include_router(router)
app.dependency_overrides[verify_api_key] = lambda: "test"
app.dependency_overrides[deps.get_database] = lambda: tmp_db
return TestClient(app)
class TestOnboardingAdversarial:
def test_put_false_clears_completed_at(self, pref_client):
"""PUT onboarded=false must clear completed_at to null, per criteria."""
# First mark as onboarded
r1 = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True})
assert r1.status_code == 200
assert r1.json()["completed_at"] is not None
# Now set to false — completed_at must be cleared
r2 = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": False})
assert r2.status_code == 200
data = r2.json()
assert data["onboarded"] is False
assert (
data["completed_at"] is None
), f"completed_at should be null after PUT onboarded=false, got {data['completed_at']!r}"
def test_put_false_then_get_returns_null_completed_at(self, pref_client):
"""After PUT false, GET must also return null completed_at (persisted correctly)."""
pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True})
pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": False})
resp = pref_client.get("/api/v1/preferences/onboarding")
assert resp.status_code == 200
assert (
resp.json()["completed_at"] is None
), "GET after PUT false should return null completed_at"
def test_corrupt_stored_value_falls_back_to_default(self, tmp_db, pref_client):
"""If the stored onboarding value is corrupt, GET must fall back to default.
Criteria: "corrupt stored value falls back to default".
We inject garbage into the db directly, then hit GET.
"""
# Inject a value that is syntactically valid JSON (dict) but fails
# Pydantic validation because the types are wrong.
tmp_db.set_setting("onboarded", {"onboarded": "not_a_bool", "completed_at": 12345})
resp = pref_client.get("/api/v1/preferences/onboarding")
assert (
resp.status_code == 200
), f"Expected 200 for corrupt onboarding value, got {resp.status_code}"
data = resp.json()
# Must fall back to default
assert (
data["onboarded"] is False
), f"Expected onboarded=false as default after corrupt value, got {data['onboarded']!r}"
assert (
data["completed_at"] is None
), f"Expected completed_at=null as default, got {data['completed_at']!r}"
def test_corrupt_stored_value_as_wrong_type_falls_back(self, tmp_db, pref_client):
"""Stored value is a string (not a dict) — must fall back to default."""
tmp_db.set_setting("onboarded", "this_is_not_valid")
resp = pref_client.get("/api/v1/preferences/onboarding")
assert resp.status_code == 200
data = resp.json()
assert data["onboarded"] is False
assert data["completed_at"] is None
def test_corrupt_stored_null_falls_back_to_default(self, tmp_db, pref_client):
"""Stored value is null/None — must return default (not crash)."""
tmp_db.set_setting("onboarded", None)
resp = pref_client.get("/api/v1/preferences/onboarding")
assert resp.status_code == 200
assert resp.json()["onboarded"] is False
def test_put_true_without_completed_at_stamps_timestamp(self, pref_client):
"""PUT onboarded=true without completed_at must auto-stamp a timestamp."""
resp = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True})
assert resp.status_code == 200
data = resp.json()
assert (
data["completed_at"] is not None
), "Server must auto-stamp completed_at when onboarded=true is sent without a timestamp"
# Should be a non-empty ISO timestamp string
assert len(data["completed_at"]) > 10
def test_put_true_with_completed_at_preserves_it(self, pref_client):
"""PUT onboarded=true with explicit completed_at must preserve that value."""
ts = "2025-03-15T10:00:00+00:00"
resp = pref_client.put(
"/api/v1/preferences/onboarding",
json={"onboarded": True, "completed_at": ts},
)
assert resp.status_code == 200
assert resp.json()["completed_at"] == ts
def test_put_false_with_completed_at_clears_it(self, pref_client):
"""PUT onboarded=false even with a completed_at payload must clear it.
The criteria say: 'Setting onboarded=false clears completed_at to null.'
"""
ts = "2025-01-01T00:00:00+00:00"
resp = pref_client.put(
"/api/v1/preferences/onboarding",
json={"onboarded": False, "completed_at": ts},
)
assert resp.status_code == 200
data = resp.json()
assert data["onboarded"] is False
assert (
data["completed_at"] is None
), f"completed_at should be cleared to null when onboarded=false, got {data['completed_at']!r}"
def test_multiple_true_puts_only_stamp_once(self, pref_client):
"""Two successive PUT true calls — second call must preserve the original timestamp."""
r1 = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True})
ts1 = r1.json()["completed_at"]
assert ts1 is not None
# Explicitly provide the original timestamp in second call
r2 = pref_client.put(
"/api/v1/preferences/onboarding",
json={"onboarded": True, "completed_at": ts1},
)
assert (
r2.json()["completed_at"] == ts1
), "Explicit completed_at should be preserved on second PUT"
@@ -33,6 +33,8 @@ _TOP_LEVEL_KEYS = (
"css_sources",
"value_sources",
"scene_presets",
"scene_playlists",
"playlist_state",
"sync_clocks",
"system",
)
@@ -56,6 +58,21 @@ def client(test_config, monkeypatch):
value_store.get_all_sources.return_value = []
preset_store = MagicMock()
preset_store.get_all_presets.return_value = []
playlist_store = MagicMock()
playlist_store.get_all_playlists.return_value = []
playlist_engine = MagicMock()
playlist_engine.get_running_playlist_id.return_value = None
playlist_engine.get_state.return_value = {
"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,
}
clock_store = MagicMock()
clock_store.get_all_clocks.return_value = []
clock_manager = MagicMock()
@@ -74,6 +91,8 @@ def client(test_config, monkeypatch):
app.dependency_overrides[deps.get_color_strip_store] = lambda: css_store
app.dependency_overrides[deps.get_value_source_store] = lambda: value_store
app.dependency_overrides[deps.get_scene_preset_store] = lambda: preset_store
app.dependency_overrides[deps.get_scene_playlist_store] = lambda: playlist_store
app.dependency_overrides[deps.get_playlist_engine] = lambda: playlist_engine
app.dependency_overrides[deps.get_sync_clock_store] = lambda: clock_store
app.dependency_overrides[deps.get_sync_clock_manager] = lambda: clock_manager
app.dependency_overrides[deps.get_processor_manager] = lambda: manager
@@ -97,12 +116,16 @@ def test_snapshot_returns_all_sections(client):
"css_sources",
"value_sources",
"scene_presets",
"scene_playlists",
"sync_clocks",
):
assert data[list_key] == []
for dict_key in ("target_states", "target_metrics", "device_brightness"):
assert data[dict_key] == {}
# The single global cycling state rides along with the playlist list.
assert data["playlist_state"]["is_running"] is False
def test_snapshot_system_block_has_health_version(client):
data = client.get("/api/v1/snapshot", headers=_AUTH).json()
@@ -0,0 +1,535 @@
"""Adversarial / concurrency tests for CalibrationSession.
Phase 1 acceptance criteria tested here (NOT what the code happens to do):
- Interleaved start/start (same device, then different device) must never
leave the old device without restore.
- Interleaved start/stop racing the idle watchdog must not leave the device
dark or stuck.
- Idle-timeout teardown restores the prior target.
- position() with index out of range ValueError.
- stop() when idle is a safe no-op (does not call start_processing or crash).
- CalibrationSession lock must prevent double-teardown.
All tests use a fake ProcessorManager matching the shape used in
test_calibration_routes.py.
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from ledgrab.core.capture.calibration_session import (
CalibrationSession,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_manager(device_id: str = "dev1", led_count: int = 100) -> MagicMock:
"""Build a minimal fake ProcessorManager."""
mgr = MagicMock()
ds = MagicMock()
ds.led_count = led_count
mgr._devices = {device_id: 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 fresh_session():
"""Yield a brand-new CalibrationSession for each test (not the singleton)."""
session = CalibrationSession()
yield session
# Cleanup: cancel any lingering watchdog
if session._timeout_task and not session._timeout_task.done():
session._timeout_task.cancel()
try:
await session._timeout_task
except (asyncio.CancelledError, Exception):
pass
# ---------------------------------------------------------------------------
# stop() when idle is a safe no-op
# ---------------------------------------------------------------------------
class TestStopWhenIdle:
@pytest.mark.asyncio
async def test_stop_idle_does_not_call_start_processing(self, fresh_session):
"""Calling stop() when no session is active must not call start_processing."""
mgr = _make_manager()
# Do NOT start a session — just stop immediately
await fresh_session.stop()
mgr.start_processing.assert_not_awaited()
@pytest.mark.asyncio
async def test_stop_idle_returns_inactive_state(self, fresh_session):
"""stop() on an idle session returns state with active=False."""
state = fresh_session.get_state()
assert state["active"] is False
await fresh_session.stop() # no-op
assert fresh_session.is_active is False
@pytest.mark.asyncio
async def test_cancel_idle_safe(self, fresh_session):
"""cancel() on idle session is also a safe no-op."""
await fresh_session.cancel()
assert fresh_session.is_active is False
@pytest.mark.asyncio
async def test_double_stop_is_idempotent(self, fresh_session):
"""Calling stop() twice on an active session must not double-call start_processing."""
mgr = _make_manager()
mgr.get_processing_target_for_device = MagicMock(return_value="tgt_restore")
await fresh_session.start("dev1", mgr)
assert fresh_session.is_active is True
await fresh_session.stop()
assert fresh_session.is_active is False
# Restore called exactly once
mgr.start_processing.assert_awaited_once_with("tgt_restore")
# Second stop must be a no-op
await fresh_session.stop()
# start_processing should still be called exactly once (not twice)
assert mgr.start_processing.await_count == 1
# ---------------------------------------------------------------------------
# position() out of range
# ---------------------------------------------------------------------------
class TestPositionOutOfRange:
@pytest.mark.asyncio
async def test_position_equal_to_led_count_raises(self, fresh_session):
"""index == led_count must raise ValueError (0-based, so out of range)."""
mgr = _make_manager(led_count=100)
await fresh_session.start("dev1", mgr)
with pytest.raises(ValueError, match="out of range"):
await fresh_session.position(100)
@pytest.mark.asyncio
async def test_position_above_led_count_raises(self, fresh_session):
"""index > led_count raises ValueError."""
mgr = _make_manager(led_count=50)
await fresh_session.start("dev1", mgr)
with pytest.raises(ValueError, match="out of range"):
await fresh_session.position(999)
@pytest.mark.asyncio
async def test_position_negative_raises(self, fresh_session):
"""Negative index raises ValueError."""
mgr = _make_manager(led_count=100)
await fresh_session.start("dev1", mgr)
with pytest.raises(ValueError):
await fresh_session.position(-1)
@pytest.mark.asyncio
async def test_position_at_led_count_minus_1_is_valid(self, fresh_session):
"""Last valid index (led_count - 1) must succeed."""
mgr = _make_manager(led_count=10)
await fresh_session.start("dev1", mgr)
await fresh_session.position(9) # must not raise
mgr.set_calibration_pixel.assert_awaited()
@pytest.mark.asyncio
async def test_position_without_active_session_raises(self, fresh_session):
"""position() with no active session must raise RuntimeError."""
with pytest.raises(RuntimeError, match="No active calibration session"):
await fresh_session.position(5)
# ---------------------------------------------------------------------------
# Interleaved start/start — old device must be restored
# ---------------------------------------------------------------------------
class TestInterleavedStartStart:
@pytest.mark.asyncio
async def test_start_on_same_device_restores_prior_target(self, fresh_session):
"""Starting a second session on the same device auto-stops the first.
The first device's prior target must be restored before the second session begins.
"""
mgr = _make_manager(led_count=60)
mgr.get_processing_target_for_device = MagicMock(return_value="tgt_original")
# Start first session
await fresh_session.start("dev1", mgr)
assert fresh_session.is_active is True
assert fresh_session._prior_target_id == "tgt_original"
# Now start again on the same device
# The second start should stop the first (restoring tgt_original),
# then re-query the current running target (which will be tgt_original again
# since start_processing will have been called).
# For isolation: change what get_processing_target_for_device returns after
# the first stop so the second session records a fresh prior.
call_count = {"n": 0}
def _get_target(device_id):
call_count["n"] += 1
if call_count["n"] == 1:
return "tgt_original"
return None # After first stop, no target running
mgr.get_processing_target_for_device = MagicMock(side_effect=_get_target)
# First session with the original target
fresh_session2 = CalibrationSession()
await fresh_session2.start("dev1", mgr)
assert fresh_session2.is_active is True
# Start a NEW session on the same device — must auto-stop fresh_session2
fresh_session3 = CalibrationSession()
# Inject fresh_session2's internal state into fresh_session3 to simulate
# the singleton pattern: replace session3's state to reflect session2 active
# (this mirrors the module-level singleton where only one CalibrationSession exists)
fresh_session3._active = fresh_session2._active
fresh_session3._device_id = fresh_session2._device_id
fresh_session3._led_count = fresh_session2._led_count
fresh_session3._prior_target_id = fresh_session2._prior_target_id
fresh_session3._last_activity = fresh_session2._last_activity
fresh_session3._manager = fresh_session2._manager
fresh_session3._timeout_task = fresh_session2._timeout_task
fresh_session2._timeout_task = None # prevent double-cancel
await fresh_session3.start("dev1", mgr)
# The start must have called stop on the previous session → restore was called
# (mgr.start_processing was called at least once to restore the prior target)
assert mgr.start_processing.await_count >= 1
# Cleanup
await fresh_session3.stop()
if fresh_session2._timeout_task and not fresh_session2._timeout_task.done():
fresh_session2._timeout_task.cancel()
@pytest.mark.asyncio
async def test_new_session_on_different_device_clears_old_device(self, fresh_session):
"""Starting a new session on a different device must clear the first device.
The first session must be stopped (its prior target restored or cleared)
before the second session on the new device becomes active.
"""
mgr = MagicMock()
ds1 = MagicMock()
ds1.led_count = 30
ds2 = MagicMock()
ds2.led_count = 60
mgr._devices = {"dev1": ds1, "dev2": ds2}
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()
# Start first session on dev1
await fresh_session.start("dev1", mgr)
assert fresh_session._device_id == "dev1"
assert fresh_session.is_active is True
# Now start second session on dev2 — must auto-stop dev1 first
await fresh_session.start("dev2", mgr)
# After the second start, session must be on dev2
assert fresh_session._device_id == "dev2"
assert fresh_session.is_active is True
# send_clear_pixels was called for dev1 (stop) AND for dev2 (start)
clear_calls = [call[0][0] for call in mgr.send_clear_pixels.call_args_list]
assert (
"dev1" in clear_calls
), f"dev1 was never cleared during session switch; clear calls: {clear_calls}"
assert (
"dev2" in clear_calls
), f"dev2 was never cleared at session start; clear calls: {clear_calls}"
# Cleanup
await fresh_session.stop()
# ---------------------------------------------------------------------------
# Idle-timeout teardown restores prior target
# ---------------------------------------------------------------------------
class TestIdleTimeoutRestoresPriorTarget:
@pytest.mark.asyncio
async def test_idle_timeout_calls_start_processing(self, fresh_session):
"""When the session times out, start_processing must be called to restore the target.
We patch IDLE_TIMEOUT_SECONDS to a tiny value so the test doesn't actually
wait 60 seconds.
"""
mgr = _make_manager(led_count=40)
mgr.get_processing_target_for_device = MagicMock(return_value="tgt_to_restore")
# Patch the idle timeout to 0.05 seconds
with patch(
"ledgrab.core.capture.calibration_session.IDLE_TIMEOUT_SECONDS",
0.05,
):
# Also patch the watchdog sleep to something tiny
async def _fast_watchdog():
"""A watchdog that checks every 0.02 seconds instead of 5."""
try:
while True:
await asyncio.sleep(0.02)
if not fresh_session._active or fresh_session._last_activity is None:
break
from datetime import datetime, timezone
elapsed = (
datetime.now(timezone.utc) - fresh_session._last_activity
).total_seconds()
if elapsed >= 0.05:
async with fresh_session._lock:
await fresh_session._teardown_locked(cancelled=False)
break
except asyncio.CancelledError:
pass
fresh_session._idle_watchdog = _fast_watchdog # type: ignore[method-assign]
await fresh_session.start("dev1", mgr)
assert fresh_session.is_active is True
# Wait long enough for the watchdog to fire
await asyncio.sleep(0.25)
# Session should have been auto-stopped
assert (
fresh_session.is_active is False
), "Session should have been auto-stopped by idle timeout"
# Prior target must have been restored
mgr.start_processing.assert_awaited_once_with("tgt_to_restore")
@pytest.mark.asyncio
async def test_idle_timeout_clears_device_to_black(self, fresh_session):
"""Idle timeout must send all-black before (or after) restoring target."""
mgr = _make_manager(led_count=40)
async def _fast_watchdog():
try:
while True:
await asyncio.sleep(0.02)
if not fresh_session._active or fresh_session._last_activity is None:
break
from datetime import datetime, timezone
elapsed = (
datetime.now(timezone.utc) - fresh_session._last_activity
).total_seconds()
if elapsed >= 0.05:
async with fresh_session._lock:
await fresh_session._teardown_locked(cancelled=False)
break
except asyncio.CancelledError:
pass
fresh_session._idle_watchdog = _fast_watchdog # type: ignore[method-assign]
await fresh_session.start("dev1", mgr)
initial_clear_count = mgr.send_clear_pixels.await_count # from start()
await asyncio.sleep(0.25)
# send_clear_pixels must have been called at least once more during teardown
assert (
mgr.send_clear_pixels.await_count > initial_clear_count
), "Device was not cleared to black during idle-timeout teardown"
# ---------------------------------------------------------------------------
# Concurrent start/start using asyncio.gather (true concurrency within the loop)
# ---------------------------------------------------------------------------
class TestConcurrentStartCalls:
@pytest.mark.asyncio
async def test_concurrent_starts_dont_corrupt_state(self, fresh_session):
"""Two concurrent start() calls must leave the session in a consistent state.
Only one session should be active after both complete; the final device
must match one of the two requested devices.
"""
mgr = MagicMock()
ds1 = MagicMock()
ds1.led_count = 50
ds2 = MagicMock()
ds2.led_count = 80
mgr._devices = {"devA": ds1, "devB": ds2}
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()
# Fire both concurrently — the lock must ensure exactly one wins
results = await asyncio.gather(
fresh_session.start("devA", mgr),
fresh_session.start("devB", mgr),
return_exceptions=True,
)
# Neither call should raise (both complete without exception)
for r in results:
if isinstance(r, BaseException):
pytest.fail(f"Concurrent start raised unexpectedly: {r!r}")
# Exactly one session active with a valid device
assert fresh_session.is_active is True
assert fresh_session._device_id in ("devA", "devB")
# Cleanup
await fresh_session.stop()
# ---------------------------------------------------------------------------
# stop() while watchdog is still pending (concurrent stop/watchdog)
# ---------------------------------------------------------------------------
class TestStopVsWatchdogRace:
@pytest.mark.asyncio
async def test_explicit_stop_wins_over_watchdog(self, fresh_session):
"""Explicit stop() must cleanly terminate before the watchdog fires.
After explicit stop, start_processing must be called exactly once
even if the watchdog later tries to tear down.
"""
mgr = _make_manager(led_count=100)
mgr.get_processing_target_for_device = MagicMock(return_value="tgt_explicit")
await fresh_session.start("dev1", mgr)
assert fresh_session.is_active is True
# Explicitly stop — the watchdog task should be cancelled
await fresh_session.stop()
assert fresh_session.is_active is False
# start_processing called exactly once for the prior target
mgr.start_processing.assert_awaited_once_with("tgt_explicit")
# Give the event loop a moment to confirm the watchdog didn't double-fire
await asyncio.sleep(0.05)
# Still exactly once
assert (
mgr.start_processing.await_count == 1
), "start_processing was called more than once — watchdog fired after explicit stop"
# ---------------------------------------------------------------------------
# start() with unknown device_id
# ---------------------------------------------------------------------------
class TestStartUnknownDevice:
@pytest.mark.asyncio
async def test_unknown_device_raises_valueerror(self, fresh_session):
"""start() with a device_id not in manager._devices must raise ValueError."""
mgr = _make_manager(device_id="known_device")
with pytest.raises(ValueError, match="not found"):
await fresh_session.start("does_not_exist", mgr)
@pytest.mark.asyncio
async def test_unknown_device_leaves_session_inactive(self, fresh_session):
"""After a failed start() the session must remain inactive."""
mgr = _make_manager(device_id="known_device")
try:
await fresh_session.start("no_such_device", mgr)
except ValueError:
pass
assert fresh_session.is_active is False
@pytest.mark.asyncio
async def test_failed_start_does_not_corrupt_existing_session(self, fresh_session):
"""A failed start() attempt must not corrupt an already-active session."""
mgr = MagicMock()
ds = MagicMock()
ds.led_count = 100
mgr._devices = {"dev1": ds}
mgr.get_processing_target_for_device = MagicMock(return_value="prior_tgt")
mgr.stop_processing = AsyncMock()
mgr.start_processing = AsyncMock()
mgr.send_clear_pixels = AsyncMock()
mgr.set_calibration_pixel = AsyncMock()
# Start a valid session
await fresh_session.start("dev1", mgr)
assert fresh_session.is_active is True
# Now try to start on an unknown device — should fail
try:
await fresh_session.start("unknown_device", mgr)
except ValueError:
pass
except Exception:
pass # Other errors are also acceptable
# The original session must still be active (or was cleanly stopped and
# replaced); either way the device must not be stuck in a broken state.
# The critical invariant: if active, device_id is valid.
if fresh_session.is_active:
assert fresh_session._device_id in mgr._devices
await fresh_session.stop()
# ---------------------------------------------------------------------------
# State snapshot integrity
# ---------------------------------------------------------------------------
class TestStateSnapshot:
@pytest.mark.asyncio
async def test_get_state_before_start(self, fresh_session):
state = fresh_session.get_state()
assert state["active"] is False
assert state["device_id"] is None
assert state["led_count"] == 0
assert state["prior_target_id"] is None
assert state["last_activity"] is None
@pytest.mark.asyncio
async def test_get_state_after_start(self, fresh_session):
mgr = _make_manager(led_count=60)
mgr.get_processing_target_for_device = MagicMock(return_value="saved_tgt")
await fresh_session.start("dev1", mgr)
state = fresh_session.get_state()
assert state["active"] is True
assert state["device_id"] == "dev1"
assert state["led_count"] == 60
assert state["prior_target_id"] == "saved_tgt"
assert state["last_activity"] is not None
await fresh_session.stop()
@pytest.mark.asyncio
async def test_get_state_after_stop(self, fresh_session):
mgr = _make_manager()
await fresh_session.start("dev1", mgr)
await fresh_session.stop()
state = fresh_session.get_state()
assert state["active"] is False
assert state["device_id"] is None
assert state["led_count"] == 0
assert state["prior_target_id"] is None
@@ -0,0 +1,315 @@
"""Unit tests for solve_calibration() — pure logic, runs in isolation.
Tests cover:
- All 8 (start_position × layout) combinations
- 0-LED edge (two corners tapped adjacent)
- offset pass-through
- Round-trip through build_segments()
- Wrap-around (corner_indices straddle the 0/led_count boundary)
"""
import pytest
from ledgrab.core.capture.calibration import (
EDGE_ORDER,
CalibrationConfig,
solve_calibration,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _assert_roundtrip(cfg: CalibrationConfig) -> None:
"""build_segments() must not crash and must cover the expected LED count."""
segs = cfg.build_segments()
total_from_segs = sum(s.led_count for s in segs)
expected = cfg.leds_top + cfg.leds_right + cfg.leds_bottom + cfg.leds_left
assert total_from_segs == expected, (
f"Segment total {total_from_segs} != field total {expected} " f"for cfg={cfg!r}"
)
def _edge_counts(cfg: CalibrationConfig) -> dict[str, int]:
return {
"top": cfg.leds_top,
"right": cfg.leds_right,
"bottom": cfg.leds_bottom,
"left": cfg.leds_left,
}
# ---------------------------------------------------------------------------
# Basic: bottom_left / clockwise (canonical case)
# ---------------------------------------------------------------------------
class TestBottomLeftClockwise:
"""start_position=bottom_left, layout=clockwise.
EDGE_ORDER: ["left", "top", "right", "bottom"]
Strip walk: LED 0 is at bottom-left corner, goes UP the left edge,
across the top, DOWN the right, and back along the bottom.
Corner indices for a 100-LED, 20/30/20/30 (L/T/R/B) layout:
bottom_left -> 0
top_left -> 20 (after left edge)
top_right -> 50 (after top edge)
bottom_right -> 70 (after right edge)
"""
START = "bottom_left"
LAYOUT = "clockwise"
LED_COUNT = 100
def _make_corner_indices(self) -> list[int]:
# left=20, top=30, right=20, bottom=30
return [0, 20, 50, 70] # BL, TL, TR, BR
def test_basic_counts(self):
cfg = solve_calibration(
led_count=self.LED_COUNT,
start_position=self.START,
layout=self.LAYOUT,
corner_indices=self._make_corner_indices(),
)
counts = _edge_counts(cfg)
assert counts["left"] == 20
assert counts["top"] == 30
assert counts["right"] == 20
assert counts["bottom"] == 30
def test_start_position_preserved(self):
cfg = solve_calibration(
led_count=self.LED_COUNT,
start_position=self.START,
layout=self.LAYOUT,
corner_indices=self._make_corner_indices(),
)
assert cfg.start_position == self.START
def test_layout_preserved(self):
cfg = solve_calibration(
led_count=self.LED_COUNT,
start_position=self.START,
layout=self.LAYOUT,
corner_indices=self._make_corner_indices(),
)
assert cfg.layout == self.LAYOUT
def test_roundtrip(self):
cfg = solve_calibration(
led_count=self.LED_COUNT,
start_position=self.START,
layout=self.LAYOUT,
corner_indices=self._make_corner_indices(),
)
_assert_roundtrip(cfg)
def test_offset_passthrough(self):
cfg = solve_calibration(
led_count=self.LED_COUNT,
start_position=self.START,
layout=self.LAYOUT,
corner_indices=self._make_corner_indices(),
offset=5,
)
assert cfg.offset == 5
_assert_roundtrip(cfg)
# ---------------------------------------------------------------------------
# All 8 combinations: smoke test (round-trip + total == led_count)
# ---------------------------------------------------------------------------
ALL_CORNERS: dict[str, list[str]] = {
# start_position: [BL, TL, TR, BR] corners in the order they appear on the strip
# for layout=clockwise. We use 100 LEDs with 25 per edge for simplicity.
"bottom_left": ["BL", "TL", "TR", "BR"],
"top_left": ["TL", "TR", "BR", "BL"],
"top_right": ["TR", "BR", "BL", "TL"],
"bottom_right": ["BR", "BL", "TL", "TR"],
}
# For each start_position × layout, what are the 4 corner indices
# when all edges have 25 LEDs (100 total)?
# EDGE_ORDER for (start, "clockwise") gives the edge walk sequence.
# We map corner names to indices by placing them at the boundaries.
def _corner_indices_25_each(start_position: str, layout: str) -> list[int]:
"""
Build corner indices assuming all 4 edges have exactly 25 LEDs.
Returns [start_corner, second_corner, third_corner, fourth_corner]
following the strip walk order defined by EDGE_ORDER.
The corners of the screen are:
top_left=TL, top_right=TR, bottom_left=BL, bottom_right=BR
Each edge start-corner is at the leading edge index; its end-corner
is at that index + led_count of that edge (mod 100).
"""
key = (start_position, layout)
order = EDGE_ORDER[key] # e.g. ["left","top","right","bottom"]
# Map edge names to their start and end screen corners
# Corner positions: start corner of each edge in strip order
result = []
led_pos = 0
for edge in order:
result.append(led_pos)
led_pos += 25
return result
@pytest.mark.parametrize("start_position", list(EDGE_ORDER))
def test_all_combinations_roundtrip_25_each(start_position):
"""All 8 (start, layout) combos with 25 LEDs/edge must round-trip."""
start_pos_str, layout = start_position # unpack tuple key
indices = _corner_indices_25_each(start_pos_str, layout)
cfg = solve_calibration(
led_count=100,
start_position=start_pos_str,
layout=layout,
corner_indices=indices,
)
counts = _edge_counts(cfg)
assert (
sum(counts.values()) == 100
), f"{start_pos_str}/{layout}: total LEDs {sum(counts.values())} != 100"
assert all(
v == 25 for v in counts.values()
), f"{start_pos_str}/{layout}: edge counts {counts} not all 25"
_assert_roundtrip(cfg)
# ---------------------------------------------------------------------------
# 0-LED edge: two corners tapped adjacent (one edge has 0 LEDs)
# ---------------------------------------------------------------------------
class TestZeroLedEdge:
"""When two consecutive corner taps are the same index, that edge has 0 LEDs."""
def test_zero_bottom_edge(self):
"""
bottom_left / clockwise, 100 LEDs.
EDGE_ORDER: left, top, right, bottom
Tap top-left and bottom-right at the same index bottom edge = 0
We place BL=0, TL=40, TR=70, BR=70 (top=30, right=0 would be wrong;
let's use BL=0, TL=25, TR=65, BR=90 for bottom=10, then make left=right=40)
Actually: make right edge 0: BL=0, TL=40, TR=60, BR=60
"""
# EDGE_ORDER for bottom_left/clockwise: ["left","top","right","bottom"]
# Strip indices: left 0..39 (40 LEDs), top 40..59 (20 LEDs), right 60..59 (0 LEDs!), bottom 60..99 (40 LEDs)
cfg = solve_calibration(
led_count=100,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 40, 60, 60], # BL, TL, TR, BR — right=0
)
counts = _edge_counts(cfg)
assert counts["left"] == 40
assert counts["top"] == 20
assert counts["right"] == 0
assert counts["bottom"] == 40
assert sum(counts.values()) == 100
_assert_roundtrip(cfg)
def test_zero_first_edge(self):
"""First edge (left) can also be 0 if corners 0 and 1 are the same."""
# EDGE_ORDER bottom_left/clockwise: ["left","top","right","bottom"]
# If BL==TL, left edge has 0 LEDs
cfg = solve_calibration(
led_count=60,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 0, 20, 40], # BL=TL, left=0
)
counts = _edge_counts(cfg)
assert counts["left"] == 0
assert counts["top"] == 20
assert counts["right"] == 20
assert counts["bottom"] == 20
assert sum(counts.values()) == 60
_assert_roundtrip(cfg)
# ---------------------------------------------------------------------------
# Wrap-around: last corner index < first (straddles the 0 boundary)
# ---------------------------------------------------------------------------
class TestWrapAround:
"""When the strip wraps: the last segment spans from some index to led_count,
then continues from 0 to the start corner. This can happen if the user
provides indices that wrap around the physical end of the strip.
"""
def test_wrap_around_bottom_edge(self):
"""
bottom_left / clockwise, 100 LEDs.
EDGE_ORDER: left, top, right, bottom.
If the user taps: BL=80, TL=10, TR=40, BR=60 (wraps)
-> left: 80..10 = (100-80)+10 = 30
-> top: 10..40 = 30
-> right:40..60 = 20
-> bottom:60..80 = 20
"""
cfg = solve_calibration(
led_count=100,
start_position="bottom_left",
layout="clockwise",
corner_indices=[80, 10, 40, 60],
)
counts = _edge_counts(cfg)
assert counts["left"] == 30
assert counts["top"] == 30
assert counts["right"] == 20
assert counts["bottom"] == 20
assert sum(counts.values()) == 100
_assert_roundtrip(cfg)
# ---------------------------------------------------------------------------
# Offset
# ---------------------------------------------------------------------------
class TestOffset:
def test_offset_stored_correctly(self):
cfg = solve_calibration(
led_count=100,
start_position="top_left",
layout="clockwise",
corner_indices=[0, 25, 50, 75],
offset=10,
)
assert cfg.offset == 10
_assert_roundtrip(cfg)
def test_offset_default_zero(self):
cfg = solve_calibration(
led_count=100,
start_position="top_left",
layout="clockwise",
corner_indices=[0, 25, 50, 75],
)
assert cfg.offset == 0
# ---------------------------------------------------------------------------
# Mode is always "simple"
# ---------------------------------------------------------------------------
def test_solve_returns_simple_mode():
cfg = solve_calibration(
led_count=80,
start_position="top_right",
layout="counterclockwise",
corner_indices=[0, 20, 40, 60],
)
assert cfg.mode == "simple"
@@ -0,0 +1,562 @@
"""Adversarial / edge-case tests for solve_calibration() and build_segments().
Criteria derived from Phase 1 acceptance criteria (NOT from what the code does):
- solve_calibration returns correct per-edge counts; result round-trips through
build_segments() and totals are preserved.
- An edge with 0 LEDs (two corners tapped adjacent) is valid.
- Wrap-around edges work correctly.
- Invalid inputs raise cleanly (ValueError).
New adversarial cases not covered by the existing 19 happy-path tests:
- All four corner_indices equal (degenerate: every edge = 0) the code must
either handle it gracefully (total=0) OR raise with a clear message.
Per criteria the total must be preserved (0 == 0) and round-trip must not crash.
- Descending / out-of-order corner_indices where wrap-around should apply.
- An edge that spans the whole strip (one edge wraps the full led_count minus
the contribution of the three zero-LED edges).
- led_count just above minimum (led_count=1) with a single LED claimed by
one edge.
- offset >= led_count: the solver stores offset verbatim; PixelMapper normalises
it via % total_leds. The build_segments() round-trip must not crash.
- Corner indices modulo led_count are used, so indices >= led_count should be
accepted and reduced.
"""
import pytest
from ledgrab.core.capture.calibration import (
EDGE_ORDER,
CalibrationConfig,
solve_calibration,
)
# ---------------------------------------------------------------------------
# Helper (same as in test_calibration_solver.py — duplicated intentionally so
# this adversarial file can run in isolation)
# ---------------------------------------------------------------------------
def _roundtrip(cfg: CalibrationConfig) -> None:
"""Assert build_segments() doesn't crash and totals match."""
segs = cfg.build_segments()
total_from_segs = sum(s.led_count for s in segs)
expected = cfg.leds_top + cfg.leds_right + cfg.leds_bottom + cfg.leds_left
assert (
total_from_segs == expected
), f"Segment total {total_from_segs} != field total {expected} for cfg={cfg!r}"
# ---------------------------------------------------------------------------
# Degenerate: all four corner indices are equal
# When all four corners are tapped at the same index every consecutive pair
# has start_idx == end_idx, so every edge gets 0 LEDs. Total = 0 is
# mathematically consistent with the input; the function should either
# return a config with 0-LED edges OR raise a clear ValueError.
# It must NOT silently return wrong counts and must NOT crash unexpectedly.
# ---------------------------------------------------------------------------
class TestAllFourCornersEqual:
"""All four corner_indices equal → every edge = 0 LEDs or clean ValueError."""
@pytest.mark.parametrize(
"start_position,layout",
[(sp, lay) for sp, lay in EDGE_ORDER.keys()],
)
def test_all_equal_returns_zero_total_or_raises(self, start_position, layout):
"""solve_calibration([k,k,k,k]) must not produce non-zero counts."""
led_count = 100
try:
cfg = solve_calibration(
led_count=led_count,
start_position=start_position,
layout=layout,
corner_indices=[0, 0, 0, 0],
)
# If it doesn't raise: total must be 0 (consistent with input)
total = cfg.leds_top + cfg.leds_right + cfg.leds_bottom + cfg.leds_left
assert (
total == 0
), f"{start_position}/{layout}: all-equal corners produced total={total}; expected 0"
# build_segments must not crash on a 0-total config
segs = cfg.build_segments()
assert segs == [], f"Expected no segments for 0-LED config, got {segs!r}"
except ValueError:
# Raising is also acceptable — as long as it's a ValueError, not a
# crash/assertion/other exception.
pass
def test_all_equal_roundtrip_does_not_crash(self):
"""Even if all edges are 0, build_segments() must not raise."""
try:
cfg = solve_calibration(
led_count=50,
start_position="top_left",
layout="clockwise",
corner_indices=[25, 25, 25, 25],
)
_roundtrip(cfg) # must not crash
except ValueError:
pass # acceptable
# ---------------------------------------------------------------------------
# Descending / out-of-order corner indices
# Out-of-order but non-wrapping: e.g. [70, 50, 30, 10] for clockwise bottom_left
# The solver uses consecutive-pair differences with wrap logic.
# Each pair (70→50, 50→30, 30→10, 10→70) has end < start, so ALL four edges
# get wrap-around counts. Total must still equal led_count.
# ---------------------------------------------------------------------------
class TestDescendingCornerIndices:
"""All four corners in descending order — every pair wraps."""
def test_descending_total_equals_led_count(self):
"""Descending indices [75, 50, 25, 0] — total must == led_count."""
# bottom_left/clockwise: edge_order=[left,top,right,bottom]
# left: 75→50 = 25, top: 50→25 = 25, right: 25→0 = 25, bottom: 0→75 = 75
# Wait — end > start for bottom: 0→75 means 75. But 0 < 75 so count=75-0=75.
# Recalculate: for bottom: start_idx=0, end_idx=75 → end>start → count=75
# total = 25+25+25+75 = 150 ≠ 100 → That's wrong input.
# Use [80, 60, 40, 20] for 100 LEDs:
# left: 80→60: end<start → wrap: (100-80)+60=80 WRONG too.
# Actually for a valid descending case that totals 100:
# Monotone descending with wrap on last pair only:
# [75, 50, 25, 0] for bottom_left/clockwise:
# left: start=75,end=50: 50<75 → wrap: (100-75)+50=75 NO
# All pairs descend when [75,50,25,0]:
# 0→75 (last pair, bottom): 75>0 → count=75-0=75
# 75→50 (left): 50<75 → wrap: (100-75)+50=75
# Total would be 75+25+25+75=200 ≠ 100
# That shows descending indices don't sum to led_count in general.
# The critical invariant is: sum of per-edge counts == led_count when
# the corner indices span a single full traversal.
# To get descending [80,60,40,20] → sum via wrap logic:
# left: 80→60: 60<80 → (100-80)+60=80
# top: 60→40: 40<60 → (100-60)+40=80
# right: 40→20: 20<40 → (100-40)+20=80
# bottom: 20→80: 80>20 → 80-20=60
# total = 80+80+80+60 = 300 — still not 100.
#
# The INVARIANT of solve_calibration is: if corner_indices form a valid
# partition of the strip (i.e. each consecutive pair covers a segment
# that together span exactly led_count LEDs), the total == led_count.
# Descending indices that form valid partitions do sum to led_count.
# Example: if we want all edges wrapped, use [99, 74, 49, 24]:
# left: 99→74: 74<99 → (100-99)+74=75
# top: 74→49: 49<74 → (100-74)+49=75
# right: 49→24: 24<49 → (100-49)+24=75
# bottom: 24→99: 99>24 → 99-24=75
# total = 75+75+75+75=300 ≠ 100
#
# Conclusion: descending indices don't need to sum to led_count — only
# a proper strip traversal (covering each LED exactly once) does.
# The adversarial test here is: the function must NOT crash, must NOT
# produce negative counts, and must round-trip cleanly.
cfg = solve_calibration(
led_count=100,
start_position="bottom_left",
layout="clockwise",
corner_indices=[80, 60, 40, 20],
)
counts = [cfg.leds_top, cfg.leds_right, cfg.leds_bottom, cfg.leds_left]
assert all(c >= 0 for c in counts), f"Negative edge counts: {counts}"
_roundtrip(cfg)
def test_all_four_wrap_around_non_negative(self):
"""Every consecutive pair wraps (descending) — all counts must be >= 0."""
# [90, 70, 50, 30] for led_count=100, top_right/clockwise
cfg = solve_calibration(
led_count=100,
start_position="top_right",
layout="clockwise",
corner_indices=[90, 70, 50, 30],
)
counts = [cfg.leds_top, cfg.leds_right, cfg.leds_bottom, cfg.leds_left]
assert all(c >= 0 for c in counts), f"Negative edge counts: {counts}"
_roundtrip(cfg)
# ---------------------------------------------------------------------------
# Edge that wraps the whole strip
# When only one edge is used (the other 3 are 0 LEDs), that edge should span
# led_count LEDs. Corner indices: [0, 0, 0, 0] gives all-zero (tested above).
# A single-edge case: [0, 100, 100, 100] would give left=100, top=0, right=0,
# bottom=0 for bottom_left/clockwise. But 100 % 100 = 0 so end_idx=0,
# start_idx=0 → count=0 for left too. Use index 100 directly (>led_count).
# Alternatively: for led_count=100, corners [0, 0, 0, 0] with all-zero is
# already tested. The wrap-all case uses non-modulo indices.
#
# The real case: one valid single-edge scenario:
# bottom_left/clockwise, 100 LEDs, EDGE_ORDER = [left,top,right,bottom]
# corners [0, 100, 100, 100]: left: 0→100%100=0 → 0-LED because end==start.
# No, there's no way to make one edge claim all 100 LEDs with index-based input
# other than having it wrap around. Test: [50, 50, 50, 50] (all equal):
# already tested above.
# The closest adversarial case: one edge with count == led_count via wrap-around.
# For bottom_left/clockwise, [50, 50, 50, 50] makes top=0,right=0,bottom=0,left=0.
# To make ONE edge == 100: we need start_idx=50, end_idx=50 for three edges
# and start=50, end=50 wraps to 0 for that edge... that's the all-zeros case.
# The only way one edge gets ALL leds is:
# e.g. left: corners[0]=50, corners[1]=50 → 50==50 → count=0 (NOT 100).
# There's a subtle algorithmic distinction: adjacent indices being equal → 0 LED
# vs wrap-around: if the algorithm used (start+count)%N as end, then one-edge
# spanning the whole strip would require start=end via wrap, giving count=N.
# But the current algorithm: end==start → count=0. This is the CORRECT behavior
# per the Phase 1 spec ("Adjacent taps on the same index → 0-LED edge").
# ---------------------------------------------------------------------------
class TestWholestripSingleEdge:
"""Verify that a wrap-around edge spanning led_count-3 LEDs (max when 3 others are 1 each) works."""
def test_one_large_edge_with_minimal_others(self):
"""One edge has led_count-3 LEDs, the other 3 each have 1 LED.
bottom_left/clockwise, led_count=100:
EDGE_ORDER = [left, top, right, bottom]
corners: [0, 97, 98, 99]
left: 097 = 97
top: 9798 = 1
right: 9899 = 1
bottom: 990 = 1 (wrap: (100-99)+0 = 1)
total = 97+1+1+1 = 100
"""
cfg = solve_calibration(
led_count=100,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 97, 98, 99],
)
assert cfg.leds_left == 97
assert cfg.leds_top == 1
assert cfg.leds_right == 1
assert cfg.leds_bottom == 1
assert (cfg.leds_top + cfg.leds_right + cfg.leds_bottom + cfg.leds_left) == 100
_roundtrip(cfg)
def test_last_edge_wraps_nearly_all_leds(self):
"""The last edge (bottom in bottom_left/clockwise) wraps from 3 to 0.
corners: [0, 1, 2, 3]
left: 01 = 1
top: 12 = 1
right: 23 = 1
bottom: 30 = (100-3)+0 = 97 (wrap-around)
total = 100
"""
cfg = solve_calibration(
led_count=100,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 1, 2, 3],
)
assert cfg.leds_left == 1
assert cfg.leds_top == 1
assert cfg.leds_right == 1
assert cfg.leds_bottom == 97
assert (cfg.leds_top + cfg.leds_right + cfg.leds_bottom + cfg.leds_left) == 100
_roundtrip(cfg)
# ---------------------------------------------------------------------------
# led_count just above minimum
# led_count=1: only 1 LED; corner_indices can only meaningfully be [0,0,0,0]
# which gives all zeros. That's the all-equal case above.
# led_count=5 (just above conceptual minimum) with a valid single-wrap partition.
# ---------------------------------------------------------------------------
class TestMinimalLedCount:
"""Edge cases around very small led_count values."""
def test_led_count_1_all_equal_indices(self):
"""led_count=1: the only valid index is 0; all-equal → all-zero edges."""
try:
cfg = solve_calibration(
led_count=1,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 0, 0, 0],
)
total = cfg.leds_top + cfg.leds_right + cfg.leds_bottom + cfg.leds_left
assert total == 0, f"Expected total=0, got {total}"
_roundtrip(cfg)
except ValueError:
pass # also acceptable
def test_led_count_4_minimal_partition(self):
"""led_count=4, each edge gets 1 LED — minimum non-trivial partition."""
# bottom_left/clockwise EDGE_ORDER: [left, top, right, bottom]
# corners: [0, 1, 2, 3]
# left: 0→1=1, top: 1→2=1, right: 2→3=1, bottom: 3→0=(4-3)+0=1
cfg = solve_calibration(
led_count=4,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 1, 2, 3],
)
assert cfg.leds_left == 1
assert cfg.leds_top == 1
assert cfg.leds_right == 1
assert cfg.leds_bottom == 1
_roundtrip(cfg)
def test_led_count_0_raises_valueerror(self):
"""led_count=0 is explicitly invalid per the docstring."""
with pytest.raises(ValueError, match="led_count"):
solve_calibration(
led_count=0,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 0, 0, 0],
)
def test_led_count_negative_raises_valueerror(self):
"""led_count=-5 is invalid."""
with pytest.raises(ValueError):
solve_calibration(
led_count=-5,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 0, 0, 0],
)
def test_led_count_5_just_above_minimum(self):
"""led_count=5 with a valid 2/1/1/1 partition."""
# bottom_left/clockwise: [left,top,right,bottom]
# corners: [0, 2, 3, 4]
# left: 0→2=2, top: 2→3=1, right: 3→4=1, bottom: 4→0=(5-4)+0=1
cfg = solve_calibration(
led_count=5,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 2, 3, 4],
)
assert cfg.leds_left == 2
assert cfg.leds_top == 1
assert cfg.leds_right == 1
assert cfg.leds_bottom == 1
assert sum([cfg.leds_top, cfg.leds_right, cfg.leds_bottom, cfg.leds_left]) == 5
_roundtrip(cfg)
# ---------------------------------------------------------------------------
# Offset interactions
# The offset is stored verbatim; PixelMapper normalises it via % total_leds.
# solve_calibration must pass it through without modification.
# Also test: offset == 0 (explicit), offset == led_count (should store as-is),
# and offset >> led_count.
# ---------------------------------------------------------------------------
class TestOffsetInteractions:
"""Offset is stored verbatim and must not affect per-edge counts."""
def test_offset_zero_explicit(self):
cfg = solve_calibration(
led_count=100,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 25, 50, 75],
offset=0,
)
assert cfg.offset == 0
_roundtrip(cfg)
def test_offset_equals_led_count_stored_verbatim(self):
"""offset=led_count should be stored as-is (not reduced)."""
cfg = solve_calibration(
led_count=100,
start_position="top_left",
layout="clockwise",
corner_indices=[0, 25, 50, 75],
offset=100,
)
assert cfg.offset == 100
_roundtrip(cfg)
def test_large_offset_stored_verbatim(self):
"""offset >> led_count — stored verbatim, build_segments must not crash."""
cfg = solve_calibration(
led_count=60,
start_position="top_right",
layout="counterclockwise",
corner_indices=[0, 15, 30, 45],
offset=9999,
)
assert cfg.offset == 9999
_roundtrip(cfg)
def test_offset_does_not_change_edge_counts(self):
"""Two calls with different offsets must produce identical edge counts."""
kwargs = dict(
led_count=100,
start_position="bottom_left",
layout="counterclockwise",
corner_indices=[0, 25, 50, 75],
)
cfg_no_offset = solve_calibration(**kwargs, offset=0)
cfg_offset_13 = solve_calibration(**kwargs, offset=13)
assert cfg_no_offset.leds_top == cfg_offset_13.leds_top
assert cfg_no_offset.leds_right == cfg_offset_13.leds_right
assert cfg_no_offset.leds_bottom == cfg_offset_13.leds_bottom
assert cfg_no_offset.leds_left == cfg_offset_13.leds_left
# ---------------------------------------------------------------------------
# corner_indices >= led_count (modulo reduction)
# The solver applies % led_count to each index before computing counts.
# Index 150 for led_count=100 should behave identically to index 50.
# ---------------------------------------------------------------------------
class TestCornerIndicesModuloReduction:
"""Indices >= led_count are reduced modulo led_count before use."""
def test_indices_above_led_count_reduced(self):
"""corner_indices [0, 25, 50, 75] and [100, 125, 150, 175] must give same result."""
led_count = 100
cfg_base = solve_calibration(
led_count=led_count,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 25, 50, 75],
)
cfg_shifted = solve_calibration(
led_count=led_count,
start_position="bottom_left",
layout="clockwise",
corner_indices=[100, 125, 150, 175], # each + 100 (≡ same mod 100)
)
assert cfg_base.leds_left == cfg_shifted.leds_left
assert cfg_base.leds_top == cfg_shifted.leds_top
assert cfg_base.leds_right == cfg_shifted.leds_right
assert cfg_base.leds_bottom == cfg_shifted.leds_bottom
_roundtrip(cfg_shifted)
def test_index_exactly_led_count_is_zero(self):
"""Index = led_count reduces to 0, same as explicit 0."""
cfg_zero = solve_calibration(
led_count=100,
start_position="top_left",
layout="clockwise",
corner_indices=[0, 25, 50, 75],
)
cfg_hundred = solve_calibration(
led_count=100,
start_position="top_left",
layout="clockwise",
corner_indices=[100, 25, 50, 75], # first index = led_count → reduces to 0
)
assert cfg_zero.leds_top == cfg_hundred.leds_top
assert cfg_zero.leds_right == cfg_hundred.leds_right
assert cfg_zero.leds_bottom == cfg_hundred.leds_bottom
assert cfg_zero.leds_left == cfg_hundred.leds_left
# ---------------------------------------------------------------------------
# Invalid input: wrong number of corner_indices
# ---------------------------------------------------------------------------
class TestInvalidCornerIndicesLength:
"""Wrong number of corner indices must raise ValueError."""
def test_three_corners_raises(self):
with pytest.raises(ValueError):
solve_calibration(
led_count=100,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 25, 50],
)
def test_five_corners_raises(self):
with pytest.raises(ValueError):
solve_calibration(
led_count=100,
start_position="bottom_left",
layout="clockwise",
corner_indices=[0, 20, 40, 60, 80],
)
def test_empty_corners_raises(self):
with pytest.raises(ValueError):
solve_calibration(
led_count=100,
start_position="bottom_left",
layout="clockwise",
corner_indices=[],
)
def test_one_corner_raises(self):
with pytest.raises(ValueError):
solve_calibration(
led_count=100,
start_position="bottom_left",
layout="clockwise",
corner_indices=[50],
)
# ---------------------------------------------------------------------------
# Invalid start_position / layout
# ---------------------------------------------------------------------------
class TestInvalidEnumInputs:
def test_invalid_start_position_raises(self):
with pytest.raises(ValueError, match="start_position"):
solve_calibration(
led_count=100,
start_position="center",
layout="clockwise",
corner_indices=[0, 25, 50, 75],
)
def test_invalid_layout_raises(self):
with pytest.raises(ValueError):
solve_calibration(
led_count=100,
start_position="bottom_left",
layout="diagonal",
corner_indices=[0, 25, 50, 75],
)
def test_both_invalid_raises(self):
with pytest.raises(ValueError):
solve_calibration(
led_count=100,
start_position="wrong",
layout="wrong",
corner_indices=[0, 25, 50, 75],
)
# ---------------------------------------------------------------------------
# Totals preservation invariant
# For any valid input, sum(edge_counts) == sum via build_segments()
# Test across all 8 combinations with a wrap-around partition.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("start_position,layout", list(EDGE_ORDER.keys()))
def test_total_preservation_with_wraparound(start_position, layout):
"""For all 8 combinations with a wrap-around partition, total preserved."""
# The wrap-around partition: first 3 edges get 20 LEDs each, last edge
# gets the remaining 40 via wrap.
# EDGE_ORDER tells us walk order; corners: [0, 20, 40, 60] — last edge wraps to 40.
# bottom: 60→0 = (100-60)+0 = 40.
cfg = solve_calibration(
led_count=100,
start_position=start_position,
layout=layout,
corner_indices=[0, 20, 40, 60],
)
# Each of first 3 edges = 20, last = 40
total = cfg.leds_top + cfg.leds_right + cfg.leds_bottom + cfg.leds_left
assert total == 100, f"{start_position}/{layout}: expected total=100, got {total}"
_roundtrip(cfg)
+317
View File
@@ -0,0 +1,317 @@
"""Tests for PlaylistEngine — the scene-playlist auto-cycling runtime.
The engine dwells on each scene for ``MIN_DURATION_SECONDS`` (1s) at minimum,
so these tests patch ``asyncio.sleep`` to a tiny real delay (``fast_sleep``)
to keep the cycling deterministic and fast. A captured ``_REAL_SLEEP`` is used
for the test's own real-time waits so they aren't shortened by the patch.
"""
import asyncio
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from ledgrab.core.scenes.playlist_engine import PlaylistEngine, PlaylistError
from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
from ledgrab.storage.scene_preset import ScenePreset
from ledgrab.storage.scene_preset_store import ScenePresetStore
_REAL_SLEEP = asyncio.sleep
@pytest.fixture
def fast_sleep():
"""Patch the engine's dwell sleep to a tiny real delay."""
async def _fast(_duration):
await _REAL_SLEEP(0.005)
with patch("asyncio.sleep", _fast):
yield
@pytest.fixture
def preset_store(tmp_db) -> ScenePresetStore:
store = ScenePresetStore(tmp_db)
for sid, name in [("scene_a", "A"), ("scene_b", "B"), ("scene_c", "C")]:
now = datetime.now(timezone.utc)
store.create_preset(
ScenePreset(id=sid, name=name, targets=[], created_at=now, updated_at=now)
)
return store
@pytest.fixture
def playlist_store(tmp_db) -> ScenePlaylistStore:
return ScenePlaylistStore(tmp_db)
@pytest.fixture
def applied():
"""A list that records the preset ids the engine applies, in order."""
return []
@pytest.fixture
def engine(playlist_store, preset_store, applied):
manager = MagicMock()
manager.fire_event = MagicMock()
target_store = MagicMock()
eng = PlaylistEngine(
playlist_store=playlist_store,
scene_preset_store=preset_store,
target_store=target_store,
processor_manager=manager,
)
async def _fake_apply(preset, _ts, _mgr):
applied.append(preset.id)
return ("activated", [])
# apply_scene_state is imported lazily inside the engine, so patch it at
# its definition site with a plain async function (unambiguous awaiting).
patcher = patch(
"ledgrab.core.scenes.scene_activator.apply_scene_state",
new=_fake_apply,
)
patcher.start()
eng._apply_patcher = patcher # keep a handle for teardown
yield eng
patcher.stop()
def _make_playlist(store, name, item_specs, **kwargs) -> ScenePlaylist:
import uuid
items = [PlaylistItem(sid, dur) for sid, dur in item_specs]
pl = ScenePlaylist(
id=f"playlist_{uuid.uuid4().hex[:8]}",
name=name,
items=items,
order=store.count(),
**kwargs,
)
return store.create_playlist(pl)
async def _drain(engine, timeout=2.0):
"""Await the engine's current cycling task (for non-loop playlists)."""
task = engine._task
if task is not None:
await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
# ---------------------------------------------------------------------------
# Start validation
# ---------------------------------------------------------------------------
class TestStartValidation:
async def test_start_unknown_raises(self, engine):
with pytest.raises(PlaylistError):
await engine.start_playlist("missing")
async def test_start_empty_playlist_raises(self, engine, playlist_store):
pl = _make_playlist(playlist_store, "Empty", [])
with pytest.raises(PlaylistError):
await engine.start_playlist(pl.id)
assert engine.is_running() is False
async def test_initial_state_set_on_start(self, engine, playlist_store, fast_sleep):
pl = _make_playlist(playlist_store, "Loopy", [("scene_a", 50), ("scene_b", 50)], loop=True)
state = await engine.start_playlist(pl.id)
try:
assert state.current_index == 0
assert state.current_preset_id == "scene_a"
s = engine.get_state()
assert s["is_running"] is True
assert s["playlist_id"] == pl.id
assert s["item_count"] == 2
finally:
await engine.stop()
# ---------------------------------------------------------------------------
# Cycling behaviour
# ---------------------------------------------------------------------------
class TestCycling:
async def test_non_loop_applies_all_in_order_then_idle(
self, engine, playlist_store, applied, fast_sleep
):
pl = _make_playlist(
playlist_store,
"Once",
[("scene_a", 50), ("scene_b", 50), ("scene_c", 50)],
loop=False,
)
await engine.start_playlist(pl.id)
await _drain(engine)
assert applied == ["scene_a", "scene_b", "scene_c"]
assert engine.is_running() is False
assert engine.get_state()["is_running"] is False
async def test_loop_keeps_cycling_until_stopped(
self, engine, playlist_store, applied, fast_sleep
):
pl = _make_playlist(
playlist_store, "Forever", [("scene_a", 50), ("scene_b", 50)], loop=True
)
await engine.start_playlist(pl.id)
await _REAL_SLEEP(0.05)
assert engine.is_running() is True
await engine.stop()
assert engine.is_running() is False
# Looped at least past the first pass.
assert len(applied) >= 3
assert applied[0] == "scene_a"
async def test_missing_preset_is_skipped(self, engine, playlist_store, applied, fast_sleep):
pl = _make_playlist(
playlist_store,
"Mixed",
[("scene_a", 50), ("ghost", 50), ("scene_b", 50)],
loop=False,
)
await engine.start_playlist(pl.id)
await _drain(engine)
assert applied == ["scene_a", "scene_b"]
async def test_all_missing_with_loop_stops(self, engine, playlist_store, applied):
pl = _make_playlist(playlist_store, "Dead", [("ghost1", 50), ("ghost2", 50)], loop=True)
await engine.start_playlist(pl.id)
await _drain(engine) # guard should break the loop immediately
assert applied == []
assert engine.is_running() is False
async def test_shuffle_uses_random_order(self, engine, playlist_store, applied, fast_sleep):
pl = _make_playlist(
playlist_store,
"Shuffled",
[("scene_a", 50), ("scene_b", 50), ("scene_c", 50)],
loop=False,
shuffle=True,
)
def _reverse(seq):
seq.reverse()
with patch("ledgrab.core.scenes.playlist_engine.random.shuffle", side_effect=_reverse):
await engine.start_playlist(pl.id)
await _drain(engine)
assert applied == ["scene_c", "scene_b", "scene_a"]
# ---------------------------------------------------------------------------
# Single-playlist exclusivity + stop helpers
# ---------------------------------------------------------------------------
class TestExclusivityAndStop:
async def test_starting_second_playlist_replaces_first(
self, engine, playlist_store, fast_sleep
):
a = _make_playlist(playlist_store, "A", [("scene_a", 50)], loop=True)
b = _make_playlist(playlist_store, "B", [("scene_b", 50)], loop=True)
await engine.start_playlist(a.id)
await engine.start_playlist(b.id)
try:
assert engine.get_running_playlist_id() == b.id
finally:
await engine.stop()
async def test_stop_when_idle_is_noop(self, engine):
await engine.stop() # should not raise
assert engine.is_running() is False
async def test_stop_if_running_only_matching(self, engine, playlist_store, fast_sleep):
a = _make_playlist(playlist_store, "A", [("scene_a", 50)], loop=True)
await engine.start_playlist(a.id)
await engine.stop_if_running("some-other-id")
assert engine.is_running() is True
await engine.stop_if_running(a.id)
assert engine.is_running() is False
async def test_get_state_idle_shape(self, engine):
s = engine.get_state()
assert s["is_running"] is False
assert s["playlist_id"] is None
assert s["current_index"] == 0
# ---------------------------------------------------------------------------
# Event firing — the playlist_state_changed contract the frontend WS layer
# (events-ws.ts allowlist + scene-playlists.ts listener) depends on. A dropped
# event here would silently freeze the UI's running indicator, yet the
# is_running()/ordering assertions above would stay green — so assert the
# fire_event payloads directly.
# ---------------------------------------------------------------------------
def _fired_events(engine) -> list:
"""All payloads passed to processor_manager.fire_event, in order."""
return [c.args[0] for c in engine._manager.fire_event.call_args_list]
def _fired_actions(engine) -> list:
return [e.get("action") for e in _fired_events(engine)]
class TestEvents:
async def test_start_fires_started_event(self, engine, playlist_store, fast_sleep):
pl = _make_playlist(playlist_store, "Started", [("scene_a", 50)], loop=True)
await engine.start_playlist(pl.id)
try:
started = [e for e in _fired_events(engine) if e.get("action") == "started"]
assert len(started) == 1
assert started[0]["type"] == "playlist_state_changed"
assert started[0]["playlist_id"] == pl.id
finally:
await engine.stop()
async def test_non_loop_completion_fires_final_stopped(
self, engine, playlist_store, fast_sleep
):
pl = _make_playlist(
playlist_store, "Finishes", [("scene_a", 50), ("scene_b", 50)], loop=False
)
await engine.start_playlist(pl.id)
await _drain(engine)
actions = _fired_actions(engine)
assert actions[0] == "started"
assert actions[-1] == "stopped"
# The natural-completion 'stopped' must carry the playlist id even
# though _run clears _state to None before firing (ended_id capture).
stopped = [e for e in _fired_events(engine) if e.get("action") == "stopped"]
assert stopped and stopped[-1]["playlist_id"] == pl.id
async def test_advanced_fires_per_applied_item_only(self, engine, playlist_store, fast_sleep):
pl = _make_playlist(
playlist_store,
"Mixed",
[("scene_a", 50), ("ghost", 50), ("scene_b", 50)],
loop=False,
)
await engine.start_playlist(pl.id)
await _drain(engine)
advanced = [e for e in _fired_events(engine) if e.get("action") == "advanced"]
# Only the two real presets advance; the missing one fires nothing.
assert [e["preset_id"] for e in advanced] == ["scene_a", "scene_b"]
assert [e["index"] for e in advanced] == [0, 2]
async def test_explicit_stop_fires_stopped(self, engine, playlist_store, fast_sleep):
pl = _make_playlist(playlist_store, "Stopper", [("scene_a", 50)], loop=True)
await engine.start_playlist(pl.id)
await engine.stop()
stopped = [e for e in _fired_events(engine) if e.get("action") == "stopped"]
assert stopped and stopped[-1]["playlist_id"] == pl.id
async def test_stop_when_idle_fires_nothing(self, engine):
await engine.stop()
assert _fired_actions(engine) == []
@@ -0,0 +1,189 @@
"""Tests for ScenePlaylist model + ScenePlaylistStore."""
import pytest
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.scene_playlist import (
DEFAULT_DURATION_SECONDS,
MAX_DURATION_SECONDS,
MIN_DURATION_SECONDS,
PlaylistItem,
ScenePlaylist,
clamp_duration,
)
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
@pytest.fixture
def store(tmp_db) -> ScenePlaylistStore:
return ScenePlaylistStore(tmp_db)
def _make_playlist(store: ScenePlaylistStore, name="My Playlist", **kwargs) -> ScenePlaylist:
import uuid
items = kwargs.pop("items", [PlaylistItem("scene_aaa", 10.0)])
pl = ScenePlaylist(
id=f"playlist_{uuid.uuid4().hex[:8]}",
name=name,
items=items,
order=store.count(),
**kwargs,
)
return store.create_playlist(pl)
# ---------------------------------------------------------------------------
# clamp_duration
# ---------------------------------------------------------------------------
class TestClampDuration:
def test_within_range_unchanged(self):
assert clamp_duration(42.5) == 42.5
def test_below_floor_clamped(self):
assert clamp_duration(0) == MIN_DURATION_SECONDS
assert clamp_duration(-5) == MIN_DURATION_SECONDS
def test_above_ceiling_clamped(self):
assert clamp_duration(MAX_DURATION_SECONDS + 1000) == MAX_DURATION_SECONDS
def test_non_numeric_returns_default(self):
assert clamp_duration("oops") == DEFAULT_DURATION_SECONDS
assert clamp_duration(None) == DEFAULT_DURATION_SECONDS
# ---------------------------------------------------------------------------
# Model serialization round-trip
# ---------------------------------------------------------------------------
class TestModelSerialization:
def test_round_trip_preserves_fields(self):
pl = ScenePlaylist(
id="playlist_1",
name="Cycle",
description="desc",
items=[PlaylistItem("a", 5.0), PlaylistItem("b", 15.0)],
loop=False,
shuffle=True,
tags=["movie"],
icon="play",
icon_color="#fff",
order=3,
)
restored = ScenePlaylist.from_dict(pl.to_dict())
assert restored.id == "playlist_1"
assert restored.name == "Cycle"
assert restored.description == "desc"
assert restored.loop is False
assert restored.shuffle is True
assert restored.tags == ["movie"]
assert restored.icon == "play"
assert restored.icon_color == "#fff"
assert restored.order == 3
assert [(i.scene_preset_id, i.duration_seconds) for i in restored.items] == [
("a", 5.0),
("b", 15.0),
]
def test_from_dict_clamps_bad_duration(self):
data = {
"id": "playlist_x",
"name": "X",
"items": [{"scene_preset_id": "a", "duration_seconds": 0}],
}
restored = ScenePlaylist.from_dict(data)
assert restored.items[0].duration_seconds == MIN_DURATION_SECONDS
def test_to_dict_omits_empty_icon(self):
pl = ScenePlaylist(id="p", name="n")
d = pl.to_dict()
assert "icon" not in d
assert "icon_color" not in d
assert d["loop"] is True
assert d["shuffle"] is False
# ---------------------------------------------------------------------------
# Store CRUD
# ---------------------------------------------------------------------------
class TestStoreCrud:
def test_create_and_get(self, store):
pl = _make_playlist(store, "First")
fetched = store.get_playlist(pl.id)
assert fetched.name == "First"
def test_create_duplicate_name_rejected(self, store):
_make_playlist(store, "Dup")
with pytest.raises(ValueError):
_make_playlist(store, "Dup")
def test_create_empty_name_rejected(self, store):
with pytest.raises(ValueError):
_make_playlist(store, " ")
def test_get_missing_raises(self, store):
with pytest.raises(EntityNotFoundError):
store.get_playlist("nope")
def test_get_all_sorted_by_order(self, store):
_make_playlist(store, "A") # order 0
_make_playlist(store, "B") # order 1
_make_playlist(store, "C") # order 2
names = [p.name for p in store.get_all_playlists()]
assert names == ["A", "B", "C"]
def test_update_fields(self, store):
pl = _make_playlist(store, "Edit me")
updated = store.update_playlist(
pl.id,
name="Edited",
loop=False,
shuffle=True,
items=[PlaylistItem("x", 20.0)],
tags=["t"],
)
assert updated.name == "Edited"
assert updated.loop is False
assert updated.shuffle is True
assert updated.items[0].scene_preset_id == "x"
assert updated.tags == ["t"]
def test_update_duplicate_name_rejected(self, store):
_make_playlist(store, "Taken")
pl = _make_playlist(store, "Other")
with pytest.raises(ValueError):
store.update_playlist(pl.id, name="Taken")
def test_update_missing_raises(self, store):
with pytest.raises(EntityNotFoundError):
store.update_playlist("nope", name="x")
def test_delete(self, store):
pl = _make_playlist(store, "Goner")
store.delete_playlist(pl.id)
assert store.count() == 0
with pytest.raises(EntityNotFoundError):
store.get_playlist(pl.id)
def test_persistence_across_reload(self, tmp_db):
store1 = ScenePlaylistStore(tmp_db)
_make_playlist(store1, "Persisted", items=[PlaylistItem("s1", 12.0)])
# New store instance over the same DB reloads from SQLite.
store2 = ScenePlaylistStore(tmp_db)
all_pls = store2.get_all_playlists()
assert len(all_pls) == 1
assert all_pls[0].name == "Persisted"
assert all_pls[0].items[0].duration_seconds == 12.0
def test_clone_supported(self, store):
pl = _make_playlist(store, "Original")
clone = store.clone(pl.id, "Copy")
assert clone.name == "Copy"
assert clone.id != pl.id
assert clone.id.startswith("playlist_")
assert store.count() == 2
+86
View File
@@ -0,0 +1,86 @@
"""Tests for capture region-of-interest (ROI) cropping."""
import numpy as np
from ledgrab.core.capture.calibration import (
CalibrationConfig,
calibration_from_dict,
calibration_to_dict,
)
from ledgrab.core.capture.screen_capture import ScreenCapture, crop_screen_capture
def _cfg(**kw) -> CalibrationConfig:
return CalibrationConfig(layout="clockwise", start_position="bottom_left", leds_top=10, **kw)
def _sc(w: int = 100, h: int = 80) -> ScreenCapture:
return ScreenCapture(
image=np.zeros((h, w, 3), dtype=np.uint8), width=w, height=h, display_index=0
)
def test_full_frame_returns_same_object():
sc = _sc()
assert crop_screen_capture(sc, 0.0, 0.0, 1.0, 1.0) is sc
def test_center_crop_dimensions():
out = crop_screen_capture(_sc(100, 80), 0.25, 0.25, 0.5, 0.5)
assert out.width == 50 and out.height == 40
assert out.image.shape[:2] == (40, 50)
assert out.display_index == 0
def test_crop_returns_a_view_of_the_source():
sc = _sc(100, 80)
out = crop_screen_capture(sc, 0.0, 0.0, 0.5, 0.5)
out.image[0, 0] = (9, 9, 9)
assert (sc.image[0, 0] == 9).all() # mutating the view touches the source pixels
def test_partial_width_only_keeps_full_height():
out = crop_screen_capture(_sc(100, 80), 0.1, 0.0, 0.8, 1.0)
assert out.width == 80 and out.height == 80
def test_degenerate_roi_clamped_to_at_least_one_pixel():
out = crop_screen_capture(_sc(100, 80), 0.999, 0.999, 0.0, 0.0)
assert out.width >= 1 and out.height >= 1
def test_out_of_range_roi_is_clamped():
out = crop_screen_capture(_sc(100, 80), -0.5, -0.5, 2.0, 2.0)
# x<=0,y<=0,w>=1,h>=1 hits the full-frame fast path
assert out.width == 100 and out.height == 80
# --- CalibrationConfig ROI serialization ---
def test_has_roi_property():
assert _cfg().has_roi is False
assert _cfg(roi_width=0.5).has_roi is True
assert _cfg(roi_x=0.1).has_roi is True
assert _cfg(roi_height=0.9).has_roi is True
def test_roi_round_trips_through_dict():
cfg = _cfg(roi_x=0.1, roi_y=0.2, roi_width=0.6, roi_height=0.7)
d = calibration_to_dict(cfg)
assert d["roi_x"] == 0.1 and d["roi_width"] == 0.6
back = calibration_from_dict(d)
assert (back.roi_x, back.roi_y, back.roi_width, back.roi_height) == (0.1, 0.2, 0.6, 0.7)
def test_full_frame_roi_omitted_from_dict():
d = calibration_to_dict(_cfg())
assert "roi_x" not in d and "roi_width" not in d
def test_legacy_dict_without_roi_defaults_to_full_frame():
cfg = calibration_from_dict(
{"mode": "simple", "layout": "clockwise", "start_position": "bottom_left", "leds_top": 10}
)
assert cfg.has_roi is False
assert (cfg.roi_x, cfg.roi_y, cfg.roi_width, cfg.roi_height) == (0.0, 0.0, 1.0, 1.0)
+81
View File
@@ -0,0 +1,81 @@
"""Tests for built-in curated 'look' postprocessing templates."""
import pytest
from ledgrab.core.filters.registry import FilterRegistry
from ledgrab.storage.postprocessing_template import PostprocessingTemplate
from ledgrab.storage.postprocessing_template_store import (
_BUILTIN_LOOKS,
PostprocessingTemplateStore,
)
def test_builtins_are_seeded(tmp_db):
store = PostprocessingTemplateStore(tmp_db)
for key in _BUILTIN_LOOKS:
tpl = store.get_template(f"pp_builtin_{key}")
assert tpl.is_builtin is True
assert tpl.filters # non-empty chain
def test_builtin_filters_use_registered_ids(tmp_db):
store = PostprocessingTemplateStore(tmp_db)
for key in _BUILTIN_LOOKS:
tpl = store.get_template(f"pp_builtin_{key}")
for fi in tpl.filters:
assert FilterRegistry.is_registered(fi.filter_id), fi.filter_id
def test_seeding_is_idempotent(tmp_db):
PostprocessingTemplateStore(tmp_db)
store2 = PostprocessingTemplateStore(tmp_db)
ids = [t.id for t in store2.get_all_templates() if t.id.startswith("pp_builtin_")]
assert sorted(ids) == sorted(f"pp_builtin_{k}" for k in _BUILTIN_LOOKS)
def test_builtin_update_is_blocked(tmp_db):
store = PostprocessingTemplateStore(tmp_db)
with pytest.raises(ValueError, match="read-only"):
store.update_template("pp_builtin_vivid", name="Hacked")
def test_builtin_delete_is_blocked(tmp_db):
store = PostprocessingTemplateStore(tmp_db)
with pytest.raises(ValueError, match="cannot be deleted"):
store.delete_template("pp_builtin_vivid")
def test_user_template_still_editable_and_deletable(tmp_db):
store = PostprocessingTemplateStore(tmp_db)
tpl = store.create_template("My Look", filters=[])
assert tpl.is_builtin is False
store.update_template(tpl.id, description="changed")
store.delete_template(tpl.id)
with pytest.raises(ValueError):
store.get_template(tpl.id)
def test_is_builtin_round_trips_through_dict():
tpl = PostprocessingTemplate.from_dict(
{
"id": "pp_x",
"name": "x",
"filters": [],
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
"is_builtin": True,
}
)
assert tpl.is_builtin is True
assert tpl.to_dict()["is_builtin"] is True
# legacy dict without the field defaults to False
legacy = PostprocessingTemplate.from_dict(
{
"id": "pp_y",
"name": "y",
"filters": [],
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
)
assert legacy.is_builtin is False
+70
View File
@@ -0,0 +1,70 @@
"""Unit tests for automatic brightness limiting (ABL) current estimation."""
import numpy as np
import pytest
from ledgrab.core.processing.power_limit import (
DEFAULT_MILLIAMPS_PER_LED,
estimate_current_ma,
power_limit_scale,
)
def test_default_ma_per_led_constant():
assert DEFAULT_MILLIAMPS_PER_LED == 55
def test_full_white_draws_ma_per_led_times_count():
colors = np.full((100, 3), 255, dtype=np.uint8)
assert estimate_current_ma(colors, 55) == pytest.approx(100 * 55)
def test_black_draws_zero():
colors = np.zeros((100, 3), dtype=np.uint8)
assert estimate_current_ma(colors, 55) == 0.0
def test_half_white_is_half_current():
full = estimate_current_ma(np.full((100, 3), 255, dtype=np.uint8), 55)
half = estimate_current_ma(np.full((100, 3), 128, dtype=np.uint8), 55)
assert half == pytest.approx(full * 128 / 255, rel=1e-6)
def test_zero_ma_per_led_draws_zero():
colors = np.full((100, 3), 255, dtype=np.uint8)
assert estimate_current_ma(colors, 0) == 0.0
def test_empty_frame_is_safe():
colors = np.zeros((0, 3), dtype=np.uint8)
assert estimate_current_ma(colors, 55) == 0.0
assert power_limit_scale(colors, 1000, 55) == 1.0
def test_scale_is_one_when_disabled():
colors = np.full((100, 3), 255, dtype=np.uint8)
assert power_limit_scale(colors, 0, 55) == 1.0
assert power_limit_scale(colors, -1, 55) == 1.0
def test_scale_is_one_within_budget():
colors = np.full((100, 3), 255, dtype=np.uint8) # 5500 mA at 55 mA/LED
assert power_limit_scale(colors, 6000, 55) == 1.0
assert power_limit_scale(colors, 5500, 55) == 1.0 # exactly on budget
def test_scale_brings_full_white_to_budget():
colors = np.full((100, 3), 255, dtype=np.uint8) # 5500 mA
scale = power_limit_scale(colors, 2750, 55) # half budget
assert scale == pytest.approx(0.5, rel=1e-6)
def test_applying_scale_lands_within_budget():
colors = np.full((100, 3), 255, dtype=np.uint8) # 5500 mA
budget = 2750
scale = power_limit_scale(colors, budget, 55)
# Mirror the processor's fixed-point application (factor/256).
factor = int(scale * 256)
scaled = ((colors.astype(np.uint16) * factor) >> 8).astype(np.uint8)
# Fixed-point rounding can only ever round DOWN, so we never exceed budget.
assert estimate_current_ma(scaled, 55) <= budget
+78
View File
@@ -0,0 +1,78 @@
"""Tests for time-of-day automation scheduling (weekday + timezone + overnight)."""
import datetime as dt
from ledgrab.core.automations import automation_engine as ae
from ledgrab.core.automations.automation_engine import AutomationEngine, _now_in_tz
from ledgrab.storage.automation import TimeOfDayRule
_eval = AutomationEngine._evaluate_time_of_day
def _patch_now(monkeypatch, fixed: dt.datetime) -> None:
monkeypatch.setattr(ae, "_now_in_tz", lambda tz: fixed)
def test_within_window_every_day(monkeypatch):
_patch_now(monkeypatch, dt.datetime(2026, 6, 3, 20, 0))
assert _eval(TimeOfDayRule(start_time="18:00", end_time="23:00")) is True
def test_outside_window(monkeypatch):
_patch_now(monkeypatch, dt.datetime(2026, 6, 3, 12, 0))
assert _eval(TimeOfDayRule(start_time="18:00", end_time="23:00")) is False
def test_weekday_filter(monkeypatch):
fixed = dt.datetime(2026, 6, 3, 20, 0)
wd = fixed.weekday()
_patch_now(monkeypatch, fixed)
assert _eval(TimeOfDayRule("time_of_day", "18:00", "23:00", days_of_week=[wd])) is True
assert (
_eval(TimeOfDayRule("time_of_day", "18:00", "23:00", days_of_week=[(wd + 1) % 7])) is False
)
def test_overnight_evening_uses_today(monkeypatch):
fixed = dt.datetime(2026, 6, 3, 23, 0) # evening tail of a 22:00->06:00 window
wd = fixed.weekday()
_patch_now(monkeypatch, fixed)
assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[wd])) is True
assert (
_eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[(wd + 1) % 7])) is False
)
def test_overnight_morning_uses_yesterday(monkeypatch):
fixed = dt.datetime(2026, 6, 3, 3, 0) # morning tail belongs to yesterday's window
today = fixed.weekday()
yesterday = (today - 1) % 7
_patch_now(monkeypatch, fixed)
assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[yesterday])) is True
assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[today])) is False
def test_from_dict_filters_invalid_days():
rule = TimeOfDayRule.from_dict({"days_of_week": [0, 7, -1, 3, 3, "x", 2.0]})
assert rule.days_of_week == [0, 2, 3]
def test_to_dict_round_trips_new_fields():
rule = TimeOfDayRule("time_of_day", "08:00", "20:00", days_of_week=[1, 2], timezone="UTC")
d = rule.to_dict()
assert d["days_of_week"] == [1, 2]
assert d["timezone"] == "UTC"
again = TimeOfDayRule.from_dict(d)
assert again.days_of_week == [1, 2] and again.timezone == "UTC"
def test_now_in_tz_invalid_falls_back_to_local():
assert _now_in_tz("Not/AZone").tzinfo is None
def test_now_in_tz_valid_is_aware():
assert _now_in_tz("UTC").tzinfo is not None
def test_now_in_tz_empty_is_local():
assert _now_in_tz("").tzinfo is None
+94
View File
@@ -0,0 +1,94 @@
"""Unit tests for the WLED native realtime UDP packet builder."""
import numpy as np
from ledgrab.core.devices.wled_realtime_client import (
DEFAULT_REALTIME_TIMEOUT,
WledRealtimeClient,
_clamp_timeout,
)
def _rgb(n: int) -> np.ndarray:
return np.arange(n * 3, dtype=np.uint8).reshape(n, 3)
def test_drgb_small_rgb_strip():
c = WledRealtimeClient("1.2.3.4", timeout_secs=2)
pixels = _rgb(10)
packets = c.build_packets(pixels)
assert len(packets) == 1
p = packets[0]
assert p[0] == 2 # DRGB
assert p[1] == 2 # timeout seconds
assert len(p) == 2 + 10 * 3
assert p[2:] == pixels.tobytes()
def test_drgbw_sets_explicit_white_zero():
c = WledRealtimeClient("1.2.3.4", rgbw=True, timeout_secs=5)
pixels = np.full((4, 3), 200, dtype=np.uint8)
packets = c.build_packets(pixels)
assert len(packets) == 1
p = packets[0]
assert p[0] == 3 # DRGBW
assert p[1] == 5
assert len(p) == 2 + 4 * 4
body = np.frombuffer(p[2:], dtype=np.uint8).reshape(4, 4)
assert (body[:, 0:3] == 200).all()
assert (body[:, 3] == 0).all() # white channel zeroed
def test_dnrgb_chunks_large_rgb_strip():
c = WledRealtimeClient("1.2.3.4", timeout_secs=3)
n = 1000 # > 490 -> DNRGB, > 489 per chunk -> 3 packets (489+489+22)
pixels = _rgb(n)
packets = c.build_packets(pixels)
assert len(packets) == 3
# Each packet starts with [4][timeout][start_hi][start_lo]
starts = []
total_leds = 0
for p in packets:
assert p[0] == 4 # DNRGB
assert p[1] == 3 # timeout
start = (p[2] << 8) | p[3]
starts.append(start)
leds = (len(p) - 4) // 3
total_leds += leds
assert starts == [0, 489, 978]
assert total_leds == n
def test_dnrgb_reassembles_to_original():
c = WledRealtimeClient("1.2.3.4", timeout_secs=1)
n = 700
pixels = _rgb(n)
out = bytearray()
for p in c.build_packets(pixels):
out += p[4:]
assert bytes(out) == pixels.tobytes()
def test_empty_frame_no_packets():
c = WledRealtimeClient("1.2.3.4")
assert c.build_packets(np.zeros((0, 3), dtype=np.uint8)) == []
def test_timeout_clamped_to_wire_range():
assert _clamp_timeout(0) == 1
assert _clamp_timeout(-5) == 1
assert _clamp_timeout(255) == 255
assert _clamp_timeout(1000) == 255
assert WledRealtimeClient("h", timeout_secs=0).timeout_secs == 1
def test_rgbw_over_capacity_falls_back_to_dnrgb():
# 400 RGBW LEDs (> 367) can't use DRGBW; falls back to DNRGB (RGB).
c = WledRealtimeClient("1.2.3.4", rgbw=True, timeout_secs=2)
packets = c.build_packets(_rgb(400))
assert all(p[0] == 4 for p in packets) # DNRGB
def test_default_timeout_constant():
assert DEFAULT_REALTIME_TIMEOUT == 2
assert WledRealtimeClient("h").timeout_secs == 2