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.
This commit is contained in:
+136
-2
@@ -185,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 |
|
||||
| ------ | ---- | ----------- |
|
||||
@@ -199,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
|
||||
|
||||
@@ -238,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
|
||||
|
||||
@@ -649,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 0–63). `calibration` is an optional `CalibrationConfig`
|
||||
dict; when omitted, `create_default_calibration(led_count)` is used.
|
||||
|
||||
**Response (201 Created):**
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "device_abc123",
|
||||
"capture_template_id": "tpl_11223344",
|
||||
"picture_source_id": "ps_aabbccdd",
|
||||
"color_strip_source_id": "css_11223344",
|
||||
"output_target_id": "pt_aabbccdd",
|
||||
"capture_template_reused": true
|
||||
}
|
||||
```
|
||||
|
||||
`capture_template_reused` is `true` when an existing template matched the
|
||||
platform engine (no new template was created).
|
||||
|
||||
**Rollback:** if any step fails, all entities created within the same call are
|
||||
deleted in reverse order so no orphans remain. The pre-existing device and any
|
||||
reused template are never deleted. Entity "created" events are emitted only
|
||||
after the full chain succeeds, so a rollback never produces ghost UI cards.
|
||||
|
||||
## Web UI & PWA
|
||||
|
||||
App-level routes served by FastAPI (not under `/api/v1`).
|
||||
|
||||
+42
-1
@@ -54,7 +54,48 @@ When you attach a device, a default calibration is created:
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Calibration
|
||||
## Automatic Calibration
|
||||
|
||||
The easiest way to calibrate your strip is the **Auto-Calibrate** wizard, available directly
|
||||
from the calibration modal. No LED counting required — just answer three questions and tap four
|
||||
corners.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A **Color Strip Source** (not a device-only target) associated with the strip.
|
||||
- A **WLED device** connected and reachable by LedGrab.
|
||||
|
||||
### How to Start
|
||||
|
||||
1. Open the **Calibration** modal for your strip source (pencil icon → Calibration tab).
|
||||
2. Click the **Auto-calibrate** button in the modal footer.
|
||||
3. Follow the five-step wizard.
|
||||
|
||||
### Wizard Steps
|
||||
|
||||
| Step | What you do |
|
||||
| ---- | ----------- |
|
||||
| 1. Device | Select the WLED device that drives the strip. |
|
||||
| 2. Start corner | LED #0 lights up on your device. Tap the corner where you see it. |
|
||||
| 3. Direction | Sweep a few LEDs light up in sequence. Tap the direction they move. |
|
||||
| 4. Mark corners | Use the step buttons to sweep to each remaining corner, then tap **Mark corner**. Repeat for all 4 corners. |
|
||||
| 5. Preview & Save | Review the detected layout (start position, direction, LED counts per edge). Click **Save** to apply. |
|
||||
|
||||
### What Happens in the Background
|
||||
|
||||
- A calibration session takes exclusive control of the device for the duration of the wizard;
|
||||
any previously running effect is paused and automatically restored when the wizard exits
|
||||
(whether by saving, cancelling, or closing the modal).
|
||||
- The solved `CalibrationConfig` is written directly to the Color Strip Source via the existing
|
||||
PUT endpoint and takes effect immediately (no restart needed).
|
||||
|
||||
### Tips
|
||||
|
||||
- If LED #0 is hard to see, reduce ambient lighting briefly.
|
||||
- The wizard works in the browser — desktop and Android TV app both supported.
|
||||
- If you make a mistake in step 4, use **Step back** to re-mark the previous corner.
|
||||
|
||||
## Manual Calibration
|
||||
|
||||
### Step 1: Identify Your LED Layout
|
||||
|
||||
|
||||
@@ -36,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)
|
||||
@@ -72,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"]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,330 @@
|
||||
"""Setup scaffold endpoint.
|
||||
|
||||
Wires a complete capture → color-strip → output chain in one call, with
|
||||
automatic rollback if any step fails so no orphan entities are left behind.
|
||||
|
||||
POST /api/v1/setup/scaffold
|
||||
Body: ScaffoldRequest — device_id (required, must already exist),
|
||||
display_index, optional calibration dict.
|
||||
Returns: ScaffoldResponse — ids of every created/reused entity.
|
||||
Fires ``entity_changed`` events for every entity created in this call,
|
||||
but ONLY after the full chain succeeds (no mid-chain events).
|
||||
Does NOT auto-start the target (the frontend starts it after calibration).
|
||||
|
||||
Rollback contract
|
||||
-----------------
|
||||
Entities created during THIS request are tracked in a local list. If any
|
||||
step raises, they are deleted in reverse-creation order before re-raising.
|
||||
Because "created" events are deferred until after the chain completes, a
|
||||
rollback never produces ghost UI cards — no event for a rolled-back entity
|
||||
is ever emitted.
|
||||
|
||||
The device is never part of the rollback set: scaffold requires an existing
|
||||
device (created via ``POST /api/v1/devices`` which runs full validation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_device_store,
|
||||
get_output_target_store,
|
||||
get_picture_source_store,
|
||||
get_processor_manager,
|
||||
get_template_store,
|
||||
)
|
||||
from ledgrab.api.schemas.setup import ScaffoldRequest, ScaffoldResponse
|
||||
from ledgrab.core.capture.calibration import calibration_from_dict, create_default_calibration
|
||||
from ledgrab.core.capture_engines.factory import EngineRegistry
|
||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.storage.picture_source_store import PictureSourceStore
|
||||
from ledgrab.storage import DeviceStore
|
||||
from ledgrab.storage.template_store import TemplateStore
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_DEFAULT_TARGET_FPS = 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: capture template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_or_create_capture_template(
|
||||
template_store: TemplateStore,
|
||||
created_ids: list[tuple[str, str]],
|
||||
) -> tuple[str, bool]:
|
||||
"""Return (template_id, reused).
|
||||
|
||||
Tries to find an existing template whose engine_type matches the platform's
|
||||
best available engine. Falls back to creating a fresh one.
|
||||
"""
|
||||
best_engine = EngineRegistry.get_best_available_engine()
|
||||
if not best_engine:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="No capture engine available on this platform; cannot scaffold.",
|
||||
)
|
||||
|
||||
# Try to reuse an existing template with the same engine
|
||||
for tpl in template_store.get_all_templates():
|
||||
if tpl.engine_type == best_engine:
|
||||
logger.info(
|
||||
"Scaffold: reusing existing capture template %s (engine=%s)",
|
||||
tpl.id,
|
||||
best_engine,
|
||||
)
|
||||
return tpl.id, True
|
||||
|
||||
# None found — create a fresh one
|
||||
engine_class = EngineRegistry.get_engine(best_engine)
|
||||
default_config = engine_class.get_default_config()
|
||||
try:
|
||||
tpl = template_store.create_template(
|
||||
name=f"Scaffold capture ({best_engine})",
|
||||
engine_type=best_engine,
|
||||
engine_config=default_config,
|
||||
description="Auto-created by first-run scaffold",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
created_ids.append(("capture_template", tpl.id))
|
||||
logger.info("Scaffold: created capture template %s (engine=%s)", tpl.id, best_engine)
|
||||
return tpl.id, False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: rollback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _rollback(
|
||||
created_ids: list[tuple[str, str]],
|
||||
*,
|
||||
template_store: TemplateStore,
|
||||
picture_source_store: PictureSourceStore,
|
||||
css_store: ColorStripStore,
|
||||
output_target_store: OutputTargetStore,
|
||||
manager: ProcessorManager | None = None,
|
||||
) -> None:
|
||||
"""Delete entities created during this call, in reverse order.
|
||||
|
||||
Only entities listed in ``created_ids`` are deleted; reused/pre-existing
|
||||
entities (including the device) are never touched.
|
||||
|
||||
If *manager* is provided, any ``output_target`` entity in the rollback set
|
||||
is also unregistered from the ProcessorManager before store deletion, so no
|
||||
half-registered target is left behind.
|
||||
"""
|
||||
store_map: dict[str, Any] = {
|
||||
"capture_template": template_store,
|
||||
"picture_source": picture_source_store,
|
||||
"color_strip_source": css_store,
|
||||
"output_target": output_target_store,
|
||||
}
|
||||
for entity_type, entity_id in reversed(created_ids):
|
||||
# Unregister output targets from the processor manager first
|
||||
if entity_type == "output_target" and manager is not None:
|
||||
try:
|
||||
manager.remove_target(entity_id)
|
||||
logger.info("Scaffold rollback: unregistered target %s from manager", entity_id)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
logger.debug(
|
||||
"Scaffold rollback: manager unregister skipped for %s — %s",
|
||||
entity_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
store = store_map.get(entity_type)
|
||||
if store is None:
|
||||
logger.warning("Scaffold rollback: unknown entity type %r — skipping", entity_type)
|
||||
continue
|
||||
try:
|
||||
store.delete(entity_id)
|
||||
logger.info("Scaffold rollback: deleted %s %s", entity_type, entity_id)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Scaffold rollback: failed to delete %s %s — %s",
|
||||
entity_type,
|
||||
entity_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/setup/scaffold",
|
||||
response_model=ScaffoldResponse,
|
||||
status_code=201,
|
||||
tags=["Setup"],
|
||||
)
|
||||
async def scaffold_setup(
|
||||
data: ScaffoldRequest,
|
||||
_auth: AuthRequired,
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
template_store: TemplateStore = Depends(get_template_store),
|
||||
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
output_target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
) -> ScaffoldResponse:
|
||||
"""Create a ready-to-start LED capture chain.
|
||||
|
||||
Steps (each uses the real store create method for validation and ID gen):
|
||||
|
||||
1. Look up the existing device (404 if not found).
|
||||
2. Find or create a capture template for the platform-best engine.
|
||||
3. Create a raw picture source (``display_index`` + ``capture_template_id``).
|
||||
4. Create a picture color-strip source with either the provided calibration
|
||||
or ``create_default_calibration(led_count)``.
|
||||
5. Create a LED output target linking the device to the CSS.
|
||||
|
||||
All created entities emit ``entity_changed`` events, but ONLY after the
|
||||
full chain succeeds — events are collected and fired at the very end.
|
||||
On any error the entities created so far are deleted in reverse order
|
||||
(rollback), and no "created" events are emitted (no ghost UI cards).
|
||||
The output target is NOT started — the frontend starts it after the
|
||||
optional calibration step.
|
||||
"""
|
||||
created_ids: list[tuple[str, str]] = []
|
||||
# Deferred "created" events: (entity_type, entity_id) — fired only on success.
|
||||
pending_events: list[tuple[str, str]] = []
|
||||
|
||||
rollback_stores = dict(
|
||||
template_store=template_store,
|
||||
picture_source_store=picture_source_store,
|
||||
css_store=css_store,
|
||||
output_target_store=output_target_store,
|
||||
manager=manager,
|
||||
)
|
||||
|
||||
try:
|
||||
# ── Step 1: resolve existing device ─────────────────────────────────
|
||||
try:
|
||||
device = device_store.get(data.device_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Device not found: {data.device_id}")
|
||||
device_id = device.id
|
||||
led_count = device.led_count
|
||||
|
||||
# ── Step 2: capture template ─────────────────────────────────────────
|
||||
capture_template_id, template_reused = _get_or_create_capture_template(
|
||||
template_store, created_ids
|
||||
)
|
||||
if not template_reused:
|
||||
pending_events.append(("capture_template", capture_template_id))
|
||||
|
||||
# ── Step 3: picture source ───────────────────────────────────────────
|
||||
ps_name = f"Screen {data.display_index} (scaffold)"
|
||||
try:
|
||||
picture_source = picture_source_store.create_stream(
|
||||
name=ps_name,
|
||||
stream_type="raw",
|
||||
display_index=data.display_index,
|
||||
capture_template_id=capture_template_id,
|
||||
target_fps=_DEFAULT_TARGET_FPS,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
created_ids.append(("picture_source", picture_source.id))
|
||||
pending_events.append(("picture_source", picture_source.id))
|
||||
logger.info("Scaffold: created picture source %s", picture_source.id)
|
||||
|
||||
# ── Step 4: color-strip source ───────────────────────────────────────
|
||||
if data.calibration is not None:
|
||||
try:
|
||||
calibration = calibration_from_dict(data.calibration)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Invalid calibration dict: {exc}",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
calibration = create_default_calibration(led_count)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
css_name = "Screen capture (scaffold)"
|
||||
try:
|
||||
css = css_store.create_source(
|
||||
name=css_name,
|
||||
source_type="picture",
|
||||
picture_source_id=picture_source.id,
|
||||
calibration=calibration,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
created_ids.append(("color_strip_source", css.id))
|
||||
pending_events.append(("color_strip_source", css.id))
|
||||
logger.info("Scaffold: created color-strip source %s", css.id)
|
||||
|
||||
# ── Step 5: LED output target ────────────────────────────────────────
|
||||
target_name = "LED output (scaffold)"
|
||||
try:
|
||||
target = output_target_store.create_wled_target(
|
||||
name=target_name,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=css.id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
created_ids.append(("output_target", target.id))
|
||||
pending_events.append(("output_target", target.id))
|
||||
logger.info("Scaffold: created output target %s", target.id)
|
||||
|
||||
# ── Step 5b: register target with ProcessorManager ───────────────────
|
||||
try:
|
||||
target.register_with_manager(manager)
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
"Scaffold: could not register target %s in processor manager: %s",
|
||||
target.id,
|
||||
exc,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
_rollback(created_ids, **rollback_stores)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Scaffold: unexpected error — rolling back: %s", exc, exc_info=True)
|
||||
_rollback(created_ids, **rollback_stores)
|
||||
raise HTTPException(status_code=500, detail="Internal server error during scaffold")
|
||||
|
||||
# ── Full chain succeeded — fire all deferred "created" events ───────────
|
||||
for entity_type, entity_id in pending_events:
|
||||
fire_entity_event(entity_type, "created", entity_id)
|
||||
|
||||
logger.info(
|
||||
"Scaffold complete: device=%s tpl=%s ps=%s css=%s target=%s",
|
||||
device_id,
|
||||
capture_template_id,
|
||||
picture_source.id,
|
||||
css.id,
|
||||
target.id,
|
||||
)
|
||||
return ScaffoldResponse(
|
||||
device_id=device_id,
|
||||
capture_template_id=capture_template_id,
|
||||
picture_source_id=picture_source.id,
|
||||
color_strip_source_id=css.id,
|
||||
output_target_id=target.id,
|
||||
capture_template_reused=template_reused,
|
||||
)
|
||||
@@ -30,7 +30,9 @@ from ledgrab.api.dependencies import (
|
||||
get_color_strip_store,
|
||||
get_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
|
||||
|
||||
@@ -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")
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -668,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,
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -2283,3 +2283,739 @@ 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));
|
||||
}
|
||||
|
||||
.autocal-corner-glyph {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 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); } }
|
||||
|
||||
/* Toolbar wizard re-run button */
|
||||
.header-btn[id="wizard-rerun-btn"] { }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wizard-progress-fill,
|
||||
.wizard-pip,
|
||||
.wizard-discovery-item,
|
||||
.wizard-display-item { transition: none; }
|
||||
.btn-spinner { animation: none; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -203,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 {
|
||||
@@ -320,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,
|
||||
@@ -620,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,
|
||||
@@ -924,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) {
|
||||
|
||||
@@ -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'))}">←</button>
|
||||
<div class="autocal-led-track">
|
||||
<div class="autocal-led-track-fill" style="width:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
|
||||
<div class="autocal-led-cursor" style="left:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepForward()" ${busy || isComplete || currentIndex >= ledCount - 1 ? 'disabled' : ''}
|
||||
aria-label="${_esc(t('autocal.btn.step_fwd'))}">→</button>
|
||||
</div>
|
||||
|
||||
${isComplete ? '' : `
|
||||
<button class="btn btn-primary autocal-mark-btn" onclick="autoCalMarkCorner()" ${busy ? 'disabled' : ''}>
|
||||
${_esc(t('autocal.btn.mark_corner', { n: String(collected), label: activeCornerLabel }))}
|
||||
</button>`}
|
||||
|
||||
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||
<div class="autocal-footer">
|
||||
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||
<button class="btn btn-ghost" onclick="autoCalBackToDirection()">${_esc(t('autocal.btn.back'))}</button>
|
||||
${isComplete ? `<button class="btn btn-primary" onclick="autoCalSolve()">${_esc(t('autocal.btn.solve'))}</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
_showError(_state.errorMsg);
|
||||
}
|
||||
|
||||
function _cornerLabels(startPos: StartPosition, layout: Layout): string[] {
|
||||
const all: StartPosition[] = ['top_left', 'top_right', 'bottom_right', 'bottom_left'];
|
||||
const si = all.indexOf(startPos);
|
||||
let ordered: StartPosition[];
|
||||
if (layout === 'clockwise') {
|
||||
ordered = [all[si % 4], all[(si + 1) % 4], all[(si + 2) % 4], all[(si + 3) % 4]];
|
||||
} else {
|
||||
ordered = [all[si % 4], all[(si + 3) % 4], all[(si + 2) % 4], all[(si + 1) % 4]];
|
||||
}
|
||||
return ordered.map(c => t(`autocal.position.${c}`));
|
||||
}
|
||||
|
||||
export async function autoCalSweepForward(): Promise<void> {
|
||||
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||
const next = _state.currentIndex + 1;
|
||||
if (next >= _state.ledCount) return;
|
||||
_state.busy = true;
|
||||
try {
|
||||
await _setPosition(next);
|
||||
_state.currentIndex = next;
|
||||
_state.errorMsg = '';
|
||||
} catch (err: unknown) {
|
||||
_state.errorMsg = _errMsg(err);
|
||||
} finally {
|
||||
_state.busy = false;
|
||||
_render();
|
||||
}
|
||||
}
|
||||
|
||||
export async function autoCalSweepBack(): Promise<void> {
|
||||
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||
const prev = _state.currentIndex - 1;
|
||||
// Clamp to one past the last marked corner index to preserve monotonic ordering.
|
||||
const lastMarked = _state.cornerIndices.length > 0
|
||||
? _state.cornerIndices[_state.cornerIndices.length - 1]
|
||||
: -1;
|
||||
if (prev < 0 || prev <= lastMarked) return;
|
||||
_state.busy = true;
|
||||
try {
|
||||
await _setPosition(prev);
|
||||
_state.currentIndex = prev;
|
||||
_state.errorMsg = '';
|
||||
} catch (err: unknown) {
|
||||
_state.errorMsg = _errMsg(err);
|
||||
} finally {
|
||||
_state.busy = false;
|
||||
_render();
|
||||
}
|
||||
}
|
||||
|
||||
export async function autoCalMarkCorner(): Promise<void> {
|
||||
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||
_state.cornerIndices.push(_state.currentIndex);
|
||||
if (_state.cornerIndices.length < 4) {
|
||||
// Nudge forward so user can see the dot isn't stuck
|
||||
const next = Math.min(_state.currentIndex + 1, _state.ledCount - 1);
|
||||
_state.busy = true;
|
||||
try {
|
||||
await _setPosition(next);
|
||||
_state.currentIndex = next;
|
||||
} catch { /* best effort */ } finally {
|
||||
_state.busy = false;
|
||||
}
|
||||
}
|
||||
_render();
|
||||
}
|
||||
|
||||
export async function autoCalBackToDirection(): Promise<void> {
|
||||
if (!_state || _state.busy) return;
|
||||
_state.step = 'direction';
|
||||
_state.layout = null;
|
||||
_state.cornerIndices = [];
|
||||
_state.currentIndex = 0;
|
||||
_state.errorMsg = '';
|
||||
_render();
|
||||
await _setPosition(0).catch(() => { /* best effort */ });
|
||||
}
|
||||
|
||||
export async function autoCalSolve(): Promise<void> {
|
||||
if (!_state || _state.busy || _state.cornerIndices.length !== 4) return;
|
||||
_state.busy = true;
|
||||
_state.errorMsg = '';
|
||||
_render();
|
||||
|
||||
try {
|
||||
const solved = await apiPost<SolvedCalibration>('/calibration/solve', {
|
||||
device_id: _state.deviceId,
|
||||
start_position: _state.startPosition,
|
||||
layout: _state.layout,
|
||||
corner_indices: _state.cornerIndices,
|
||||
offset: 0,
|
||||
}, { errorMessage: t('autocal.error.solve_failed') });
|
||||
|
||||
_state.solved = solved;
|
||||
// Stop the chase session — device restored to prior target
|
||||
await _stopSession();
|
||||
_state.step = 'preview';
|
||||
} catch (err: unknown) {
|
||||
_state.errorMsg = _errMsg(err);
|
||||
_state.busy = false;
|
||||
_render();
|
||||
return;
|
||||
}
|
||||
|
||||
_state.busy = false;
|
||||
_render();
|
||||
}
|
||||
|
||||
// ── Step 5: Preview & Save ────────────────────────────────────────────────
|
||||
|
||||
function _renderPreview(): void {
|
||||
if (!_opts || !_state?.solved) return;
|
||||
const s = _state.solved;
|
||||
const busy = _state.busy;
|
||||
|
||||
const dirLabel = s.layout === 'clockwise'
|
||||
? t('calibration.direction.clockwise')
|
||||
: t('calibration.direction.counterclockwise');
|
||||
|
||||
const dirIcon = s.layout === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW;
|
||||
|
||||
_opts.container.innerHTML = `
|
||||
<div class="autocal-step" data-step="preview">
|
||||
<div class="autocal-step-header">
|
||||
<span class="autocal-step-icon autocal-step-icon--ok">${ICON_OK}</span>
|
||||
<div>
|
||||
<div class="autocal-step-title">${_esc(t('autocal.preview.title'))}</div>
|
||||
<div class="autocal-step-desc">${_esc(t('autocal.preview.desc'))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="autocal-solved-grid">
|
||||
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||
<span class="autocal-solved-key">${_esc(t('autocal.preview.start'))}</span>
|
||||
<span class="autocal-solved-val">${_esc(t(`autocal.position.${s.start_position}`))}</span>
|
||||
</div>
|
||||
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||
<span class="autocal-solved-key">${_esc(t('calibration.direction'))}</span>
|
||||
<span class="autocal-solved-val">${dirIcon} ${_esc(dirLabel)}</span>
|
||||
</div>
|
||||
<div class="autocal-solved-item">
|
||||
<span class="autocal-solved-key">${_esc(t('autocal.preview.top'))}</span>
|
||||
<span class="autocal-solved-val autocal-led-count">${s.leds_top}</span>
|
||||
</div>
|
||||
<div class="autocal-solved-item">
|
||||
<span class="autocal-solved-key">${_esc(t('autocal.preview.right'))}</span>
|
||||
<span class="autocal-solved-val autocal-led-count">${s.leds_right}</span>
|
||||
</div>
|
||||
<div class="autocal-solved-item">
|
||||
<span class="autocal-solved-key">${_esc(t('autocal.preview.bottom'))}</span>
|
||||
<span class="autocal-solved-val autocal-led-count">${s.leds_bottom}</span>
|
||||
</div>
|
||||
<div class="autocal-solved-item">
|
||||
<span class="autocal-solved-key">${_esc(t('autocal.preview.left'))}</span>
|
||||
<span class="autocal-solved-val autocal-led-count">${s.leds_left}</span>
|
||||
</div>
|
||||
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||
<span class="autocal-solved-key">${_esc(t('autocal.preview.total'))}</span>
|
||||
<span class="autocal-solved-val autocal-led-count">${s.leds_top + s.leds_right + s.leds_bottom + s.leds_left}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||
<div class="autocal-footer">
|
||||
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||
<button class="btn btn-primary" id="autocal-save-btn" onclick="autoCalSave()" ${busy ? 'disabled' : ''}>
|
||||
${_esc(t('autocal.btn.save'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
_showError(_state.errorMsg);
|
||||
}
|
||||
|
||||
export async function autoCalSave(): Promise<void> {
|
||||
if (!_state || _state.busy || !_state.solved) return;
|
||||
_state.busy = true;
|
||||
_state.errorMsg = '';
|
||||
const btn = document.getElementById('autocal-save-btn');
|
||||
if (btn) btn.setAttribute('disabled', 'true');
|
||||
|
||||
try {
|
||||
const s = _state.solved;
|
||||
await apiPut(`/color-strip-sources/${_state.cssId}`, {
|
||||
source_type: _state.cssSourceType,
|
||||
calibration: {
|
||||
mode: 'simple',
|
||||
layout: s.layout,
|
||||
start_position: s.start_position,
|
||||
leds_top: s.leds_top,
|
||||
leds_right: s.leds_right,
|
||||
leds_bottom: s.leds_bottom,
|
||||
leds_left: s.leds_left,
|
||||
offset: s.offset,
|
||||
span_top_start: 0, span_top_end: 1,
|
||||
span_right_start: 0, span_right_end: 1,
|
||||
span_bottom_start: 0, span_bottom_end: 1,
|
||||
span_left_start: 0, span_left_end: 1,
|
||||
skip_leds_start: 0,
|
||||
skip_leds_end: 0,
|
||||
border_width: 10,
|
||||
roi_x: 0, roi_y: 0, roi_width: 1, roi_height: 1,
|
||||
},
|
||||
}, { errorMessage: t('autocal.error.save_failed') });
|
||||
|
||||
colorStripSourcesCache.invalidate();
|
||||
showToast(t('autocal.saved'), 'success');
|
||||
|
||||
const onComplete = _opts?.onComplete;
|
||||
await unmountAutoCalibration();
|
||||
if (onComplete) onComplete();
|
||||
|
||||
} catch (err: unknown) {
|
||||
_state.busy = false;
|
||||
_state.errorMsg = _errMsg(err);
|
||||
if (btn) btn.removeAttribute('disabled');
|
||||
_showError(_state.errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function autoCalCancel(): Promise<void> {
|
||||
const onCancel = _opts?.onCancel;
|
||||
await unmountAutoCalibration();
|
||||
if (onCancel) onCancel();
|
||||
}
|
||||
|
||||
// ── Session lifecycle ─────────────────────────────────────────────────────
|
||||
|
||||
async function _startSession(): Promise<void> {
|
||||
if (!_state) return;
|
||||
_state.busy = true;
|
||||
_render();
|
||||
try {
|
||||
const state = await apiPost<CalibrationSessionState>('/calibration/session', {
|
||||
device_id: _state.deviceId,
|
||||
}, { errorMessage: t('autocal.error.session_start_failed') });
|
||||
_state.sessionActive = true;
|
||||
_state.ledCount = state.led_count;
|
||||
_state.busy = false;
|
||||
await _setPosition(0);
|
||||
_state.errorMsg = '';
|
||||
_render();
|
||||
} catch (err: unknown) {
|
||||
// Session may already be live (POST /calibration/session succeeded before _setPosition threw),
|
||||
// so call _stopSession() to let the backend tear down cleanly instead of flipping the flag directly.
|
||||
await _stopSession().catch(() => { /* best effort */ });
|
||||
_state.busy = false;
|
||||
_state.errorMsg = _errMsg(err);
|
||||
_render();
|
||||
}
|
||||
}
|
||||
|
||||
async function _stopSession(): Promise<void> {
|
||||
if (!_state?.sessionActive) return;
|
||||
try {
|
||||
await apiPost<CalibrationSessionState>('/calibration/session/stop', undefined, {
|
||||
errorMessage: t('autocal.error.session_stop_failed'),
|
||||
});
|
||||
} finally {
|
||||
if (_state) _state.sessionActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _setPosition(index: number): Promise<void> {
|
||||
if (!_state?.sessionActive) return;
|
||||
await apiPost<CalibrationSessionState>('/calibration/session/position', {
|
||||
index,
|
||||
window: 1,
|
||||
}, { errorMessage: t('autocal.error.position_failed') });
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||
|
||||
function _delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function _errMsg(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function _esc(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function _showError(msg: string): void {
|
||||
const el = document.getElementById('autocal-error');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.display = msg ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function _setError(msg: string): void {
|
||||
if (_state) _state.errorMsg = msg;
|
||||
_showError(msg);
|
||||
}
|
||||
|
||||
// ── Standalone modal management ───────────────────────────────────────────
|
||||
//
|
||||
// The standalone modal is the Phase 3 surface: opened from the calibration
|
||||
// modal's "Auto-calibrate" button. Phase 4 wizard uses mountAutoCalibration()
|
||||
// directly (no modal wrapper needed — the wizard is itself a modal).
|
||||
|
||||
class AutoCalModal extends Modal {
|
||||
constructor() { super('auto-calibration-modal'); }
|
||||
|
||||
snapshotValues(): Record<string, string> {
|
||||
// No dirty-check needed for a wizard flow; always allow close.
|
||||
return {};
|
||||
}
|
||||
|
||||
onForceClose(): void {
|
||||
// Unmount the flow asynchronously (session stop is async)
|
||||
unmountAutoCalibration().catch(() => { /* best effort */ });
|
||||
}
|
||||
}
|
||||
|
||||
const _autoCalModal = new AutoCalModal();
|
||||
|
||||
/**
|
||||
* Open the auto-calibration wizard for a color-strip source.
|
||||
*
|
||||
* Called from calibration.ts "Auto-calibrate" button.
|
||||
*
|
||||
* @param cssId The color-strip source ID to calibrate.
|
||||
* @param deviceId Optional pre-selected device; if omitted, the device picker
|
||||
* step is shown.
|
||||
*/
|
||||
export async function showAutoCalibration(cssId: string, deviceId?: string): Promise<void> {
|
||||
const container = document.getElementById('autocal-step-container');
|
||||
if (!container) return;
|
||||
|
||||
// Store context on the hidden inputs for reference
|
||||
const cssIdInput = document.getElementById('autocal-modal-css-id') as HTMLInputElement | null;
|
||||
const deviceIdInput = document.getElementById('autocal-modal-device-id') as HTMLInputElement | null;
|
||||
if (cssIdInput) cssIdInput.value = cssId;
|
||||
if (deviceIdInput) deviceIdInput.value = deviceId || '';
|
||||
|
||||
_autoCalModal.open();
|
||||
_autoCalModal.snapshot();
|
||||
|
||||
await mountAutoCalibration({
|
||||
container,
|
||||
cssId,
|
||||
deviceId,
|
||||
onComplete: () => {
|
||||
_autoCalModal.forceClose();
|
||||
// Reload calibration view if open
|
||||
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||
},
|
||||
onCancel: () => {
|
||||
_autoCalModal.forceClose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Close the auto-calibration modal (stops session). */
|
||||
export async function closeAutoCalModal(): Promise<void> {
|
||||
await _autoCalModal.close();
|
||||
}
|
||||
@@ -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[] = [];
|
||||
@@ -233,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) {
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
/**
|
||||
* 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 {
|
||||
const data = await apiGet<{ devices?: DiscoveredDevice[] }>('/devices/discover?timeout=3&device_type=wled');
|
||||
_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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -674,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",
|
||||
@@ -2947,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",
|
||||
@@ -3006,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:",
|
||||
@@ -3016,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:",
|
||||
@@ -3036,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:",
|
||||
@@ -3053,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",
|
||||
@@ -3205,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."
|
||||
}
|
||||
|
||||
@@ -731,6 +731,7 @@
|
||||
"common.none_own_speed": "Нет (своя скорость)",
|
||||
"common.undo": "Отменить",
|
||||
"common.cancel": "Отмена",
|
||||
"common.back": "Назад",
|
||||
"common.apply": "Применить",
|
||||
"common.start": "ПУСК",
|
||||
"common.stop": "СТОП",
|
||||
@@ -2629,7 +2630,6 @@
|
||||
"donation.about_donate": "Поддержать разработку",
|
||||
"donation.about_license": "Лицензия MIT",
|
||||
"donation.about_author": "Создатель —",
|
||||
|
||||
"streams.group.game": "Игровая интеграция",
|
||||
"tree.group.game": "Игры",
|
||||
"game_integration.section_title": "Игровые интеграции",
|
||||
@@ -2688,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": "Игровая интеграция:",
|
||||
@@ -2698,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": "Цветовой градиент:",
|
||||
@@ -2718,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": "Игровая интеграция:",
|
||||
@@ -2735,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": "Редактировать шаблон обработки звука",
|
||||
@@ -2887,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-вывод."
|
||||
}
|
||||
|
||||
@@ -727,6 +727,7 @@
|
||||
"common.none_own_speed": "无(使用自身速度)",
|
||||
"common.undo": "撤销",
|
||||
"common.cancel": "取消",
|
||||
"common.back": "返回",
|
||||
"common.apply": "应用",
|
||||
"common.start": "启动",
|
||||
"common.stop": "停止",
|
||||
@@ -2623,7 +2624,6 @@
|
||||
"donation.about_donate": "支持开发",
|
||||
"donation.about_license": "MIT 许可证",
|
||||
"donation.about_author": "作者:",
|
||||
|
||||
"streams.group.game": "游戏集成",
|
||||
"tree.group.game": "游戏",
|
||||
"game_integration.section_title": "游戏集成",
|
||||
@@ -2682,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": "游戏集成:",
|
||||
@@ -2692,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": "颜色渐变:",
|
||||
@@ -2712,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": "游戏集成:",
|
||||
@@ -2729,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": "编辑音频处理模板",
|
||||
@@ -2881,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 输出失败。"
|
||||
}
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<!-- Auto-Calibration Modal — guided chase-tap wizard.
|
||||
Opened from the calibration modal via the "Auto-calibrate" button.
|
||||
All step rendering is done by auto-calibration.ts; this shell provides
|
||||
the Modal frame and a container div that the TS mounts steps into.
|
||||
|
||||
Channel: signal (green) — same as the calibration modal's layout section.
|
||||
Max-width kept narrower than the full calibration modal (560px). -->
|
||||
<div id="auto-calibration-modal" class="modal" role="dialog" aria-modal="true"
|
||||
aria-labelledby="autocal-modal-title">
|
||||
<div class="modal-content" style="max-width:560px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="autocal-modal-title">
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/>
|
||||
<path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/>
|
||||
<path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/>
|
||||
</svg>
|
||||
<span data-i18n="autocal.modal.title">Auto-Calibrate Strip</span>
|
||||
</h2>
|
||||
<button class="modal-close-btn" onclick="closeAutoCalModal()"
|
||||
title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 20px 24px;">
|
||||
<!-- Hidden context inputs -->
|
||||
<input type="hidden" id="autocal-modal-css-id">
|
||||
<input type="hidden" id="autocal-modal-device-id">
|
||||
|
||||
<!-- Step container: auto-calibration.ts mounts here -->
|
||||
<div id="autocal-step-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,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">✕</button>
|
||||
<button class="btn btn-secondary btn-sm autocal-trigger-btn" id="calibration-auto-cal-btn"
|
||||
onclick="openAutoCalFromCalibration()"
|
||||
data-i18n-title="autocal.trigger.hint"
|
||||
title="Auto-calibrate">
|
||||
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m4.93 4.93 14.14 14.14"/><path d="M12 8v4l2 2"/></svg>
|
||||
<span data-i18n="autocal.trigger.label">Auto-calibrate</span>
|
||||
</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveCalibration()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<!-- Setup Wizard Modal — first-run guided setup.
|
||||
Opened automatically on first visit (app.ts checks onboarding flag)
|
||||
and can be reopened via the toolbar wizard button.
|
||||
|
||||
Channel: accent (green) — same as the main calibration modal.
|
||||
All step rendering is handled by setup-wizard.ts. -->
|
||||
<div id="setup-wizard-modal" class="modal" role="dialog" aria-modal="true"
|
||||
aria-labelledby="wizard-modal-title">
|
||||
<div class="modal-content" style="max-width:600px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="wizard-modal-title">
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
|
||||
<path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/>
|
||||
</svg>
|
||||
<span data-i18n="wizard.modal.title">Setup Wizard</span>
|
||||
</h2>
|
||||
<button class="modal-close-btn" onclick="wizardSkip()"
|
||||
title="Skip" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 20px 24px;">
|
||||
<!-- Progress bar and pip indicators -->
|
||||
<div id="wizard-progress-bar" class="wizard-progress-bar"></div>
|
||||
<div id="wizard-progress-labels" class="wizard-progress-labels"></div>
|
||||
|
||||
<!-- Step container: setup-wizard.ts mounts here -->
|
||||
<div id="wizard-step-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,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: 0→97 = 97
|
||||
top: 97→98 = 1
|
||||
right: 98→99 = 1
|
||||
bottom: 99→0 = 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: 0→1 = 1
|
||||
top: 1→2 = 1
|
||||
right: 2→3 = 1
|
||||
bottom: 3→0 = (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)
|
||||
Reference in New Issue
Block a user