From 0409cd8b66a4f4e088e64131af2acb161e9b488f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 8 Jun 2026 14:59:58 +0300 Subject: [PATCH 1/6] feat(calibration): auto edge-calibration backend core (phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend engine for guided LED-chase calibration, driven by the upcoming auto-calibration UI (phase 3) and first-run wizard (phase 4). - solve_calibration(): pure function mapping start corner + direction + 4 corner-tap indices to per-edge LED counts, consistent with EDGE_ORDER/ EDGE_REVERSE so it round-trips through build_segments(). - CalibrationChaseMixin.set_calibration_pixel(): light a specific LED index (+ optional window) on a device, reusing the device_test_mode idle-client send path. - CalibrationSession: single-active session with start/position/stop/cancel, a 60s idle-timeout watchdog, and a concurrency lock so interleaved calls can't corrupt the stop/restore bookkeeping — start() stops + remembers any running target on the device and stop/cancel/timeout always restore it (never leaves the device dark or stuck in chase). - Routes /api/v1/calibration/{session,session/position,session/stop, session/cancel,session/state,solve} (all AuthRequired, bounds-validated); calibration is persisted by reusing the existing PUT /color-strip-sources/ {id} (hot-reloads running streams) rather than a duplicate endpoint. - Tests: 19 solver pure-logic + 19 route/bounds. docs/API.md updated. Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate phase — full build/suite gated at the final phase). --- docs/API.md | 69 ++- server/src/ledgrab/api/__init__.py | 2 + server/src/ledgrab/api/routes/calibration.py | 236 ++++++++++ server/src/ledgrab/api/schemas/calibration.py | 103 +++++ .../src/ledgrab/core/capture/calibration.py | 92 ++++ .../core/capture/calibration_session.py | 410 ++++++++++++++++++ .../core/processing/processor_manager.py | 5 +- .../api/routes/test_calibration_routes.py | 354 +++++++++++++++ server/tests/core/test_calibration_solver.py | 315 ++++++++++++++ 9 files changed, 1584 insertions(+), 2 deletions(-) create mode 100644 server/src/ledgrab/api/routes/calibration.py create mode 100644 server/src/ledgrab/api/schemas/calibration.py create mode 100644 server/src/ledgrab/core/capture/calibration_session.py create mode 100644 server/tests/api/routes/test_calibration_routes.py create mode 100644 server/tests/core/test_calibration_solver.py diff --git a/docs/API.md b/docs/API.md index d89c378..ef09f19 100644 --- a/docs/API.md +++ b/docs/API.md @@ -238,7 +238,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 +649,73 @@ 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. + ## Web UI & PWA App-level routes served by FastAPI (not under `/api/v1`). diff --git a/server/src/ledgrab/api/__init__.py b/server/src/ledgrab/api/__init__.py index a1e710f..283e33c 100644 --- a/server/src/ledgrab/api/__init__.py +++ b/server/src/ledgrab/api/__init__.py @@ -36,6 +36,7 @@ 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 router = APIRouter() router.include_router(system_router) @@ -72,5 +73,6 @@ 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) __all__ = ["router"] diff --git a/server/src/ledgrab/api/routes/calibration.py b/server/src/ledgrab/api/routes/calibration.py new file mode 100644 index 0000000..4080e5f --- /dev/null +++ b/server/src/ledgrab/api/routes/calibration.py @@ -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, + ) diff --git a/server/src/ledgrab/api/schemas/calibration.py b/server/src/ledgrab/api/schemas/calibration.py new file mode 100644 index 0000000..c13561c --- /dev/null +++ b/server/src/ledgrab/api/schemas/calibration.py @@ -0,0 +1,103 @@ +"""Pydantic schemas for the calibration session and solver API.""" + +from typing import 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[int] = 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])." + ), + 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") diff --git a/server/src/ledgrab/core/capture/calibration.py b/server/src/ledgrab/core/capture/calibration.py index 2b04dac..2d3af4c 100644 --- a/server/src/ledgrab/core/capture/calibration.py +++ b/server/src/ledgrab/core/capture/calibration.py @@ -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, diff --git a/server/src/ledgrab/core/capture/calibration_session.py b/server/src/ledgrab/core/capture/calibration_session.py new file mode 100644 index 0000000..f80e0f5 --- /dev/null +++ b/server/src/ledgrab/core/capture/calibration_session.py @@ -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 diff --git a/server/src/ledgrab/core/processing/processor_manager.py b/server/src/ledgrab/core/processing/processor_manager.py index 5ddeda3..81b859c 100644 --- a/server/src/ledgrab/core/processing/processor_manager.py +++ b/server/src/ledgrab/core/processing/processor_manager.py @@ -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. diff --git a/server/tests/api/routes/test_calibration_routes.py b/server/tests/api/routes/test_calibration_routes.py new file mode 100644 index 0000000..e62a970 --- /dev/null +++ b/server/tests/api/routes/test_calibration_routes.py @@ -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 diff --git a/server/tests/core/test_calibration_solver.py b/server/tests/core/test_calibration_solver.py new file mode 100644 index 0000000..9957ef5 --- /dev/null +++ b/server/tests/core/test_calibration_solver.py @@ -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" From 9dcd76d264b1977c67748a392370d226e73db894 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 8 Jun 2026 15:22:04 +0300 Subject: [PATCH 2/6] feat(setup): one-call setup scaffold + onboarding flag (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend for the first-run wizard (phase 4). - POST /api/v1/setup/scaffold: given an existing device_id + display_index (+ optional calibration), wires a working chain via the real validated store create paths — create-or-reuse capture template -> raw picture source -> picture color-strip source (calibration or default) -> LED output target -> returns the ids. Does NOT auto-start. Rolls back every entity it created (reverse order) on any partial failure, leaving no orphans; "created" events are deferred until the whole chain succeeds so a rolled-back scaffold never leaves ghost cards in the UI. - Requires an existing device_id (no inline device creation) — the wizard creates the device first via the canonical, URL-validated POST /devices, so the scaffold can't bypass device validation. display_index is bounded. - GET/PUT /api/v1/preferences/onboarding: persistent first-run flag ({onboarded, completed_at}) via db.set_setting; server stamps completed_at. - Both routes AuthRequired. Tests: 25 (scaffold happy/reuse/rollback/ validation + onboarding + calibration round-trip integration). docs/API.md. Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate phase — full build/suite gated at the final phase). --- docs/API.md | 69 ++- server/src/ledgrab/api/__init__.py | 2 + server/src/ledgrab/api/routes/preferences.py | 73 +++ server/src/ledgrab/api/routes/setup.py | 299 +++++++++++ server/src/ledgrab/api/schemas/setup.py | 63 +++ server/tests/api/routes/test_setup_routes.py | 514 +++++++++++++++++++ 6 files changed, 1019 insertions(+), 1 deletion(-) create mode 100644 server/src/ledgrab/api/routes/setup.py create mode 100644 server/src/ledgrab/api/schemas/setup.py create mode 100644 server/tests/api/routes/test_setup_routes.py diff --git a/docs/API.md b/docs/API.md index ef09f19..ca31acd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 @@ -716,6 +729,60 @@ strip-walk order defined by `(start_position, layout)`. Provide either **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`). diff --git a/server/src/ledgrab/api/__init__.py b/server/src/ledgrab/api/__init__.py index 283e33c..b8c166e 100644 --- a/server/src/ledgrab/api/__init__.py +++ b/server/src/ledgrab/api/__init__.py @@ -37,6 +37,7 @@ 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) @@ -74,5 +75,6 @@ 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"] diff --git a/server/src/ledgrab/api/routes/preferences.py b/server/src/ledgrab/api/routes/preferences.py index 198ab80..9ca9a19 100644 --- a/server/src/ledgrab/api/routes/preferences.py +++ b/server/src/ledgrab/api/routes/preferences.py @@ -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"] diff --git a/server/src/ledgrab/api/routes/setup.py b/server/src/ledgrab/api/routes/setup.py new file mode 100644 index 0000000..0e3a947 --- /dev/null +++ b/server/src/ledgrab/api/routes/setup.py @@ -0,0 +1,299 @@ +"""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_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.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, +) -> 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. + """ + 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): + 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), +) -> 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, + ) + + 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) + + 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, + ) diff --git a/server/src/ledgrab/api/schemas/setup.py b/server/src/ledgrab/api/schemas/setup.py new file mode 100644 index 0000000..e2915aa --- /dev/null +++ b/server/src/ledgrab/api/schemas/setup.py @@ -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.", + ) diff --git a/server/tests/api/routes/test_setup_routes.py b/server/tests/api/routes/test_setup_routes.py new file mode 100644 index 0000000..f5a51a8 --- /dev/null +++ b/server/tests/api/routes/test_setup_routes.py @@ -0,0 +1,514 @@ +"""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 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 setup_client( + tmp_db, + device_store, + template_store, + picture_source_store, + css_store, + output_target_store, + event_log, +): + 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 + + # 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" From 9550688c1e7b7d9773fc5b250e65ee68af8dda2b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 8 Jun 2026 15:52:45 +0300 Subject: [PATCH 3/6] feat(calibration): browser-driven auto edge-calibration UI (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable, chase-driven calibration flow that solves + saves the linear CalibrationConfig with a few taps — no LED counting — and works in the browser on desktop and Android (no Tkinter dependency). - features/auto-calibration.ts: 5-step flow (start corner -> direction -> tap-to-mark-corners -> solved preview -> save). Drives the phase-1 session endpoints (session/position/solve) and persists via PUT /color-strip- sources/{id}. cornerIndices[0] is anchored to strip index 0 per the solver contract. unmountAutoCalibration() is the single cleanup gate — the calibration session is always stopped (device restored) on cancel, modal close, after save, AND on a mid-flow error, so the strip is never left dark or stuck. - Public API mountAutoCalibration({container, cssId, deviceId, onComplete, onCancel}) for the phase-4 wizard to embed; showAutoCalibration() standalone. - "Auto-calibrate" entry added to the existing calibration modal; standalone modal template; app.ts/global.d.ts exports; .autocal-* CSS matching the ds-section vocabulary; 43 autocal.* i18n keys in en/ru/zh; docs/CALIBRATION.md. Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate phase — full build/suite + test-writer gated at the final phase). --- docs/CALIBRATION.md | 43 +- server/src/ledgrab/static/css/components.css | 368 ++++++++ server/src/ledgrab/static/js/app.ts | 27 + .../static/js/features/auto-calibration.ts | 810 ++++++++++++++++++ .../ledgrab/static/js/features/calibration.ts | 28 + server/src/ledgrab/static/js/global.d.ts | 18 + server/src/ledgrab/static/locales/en.json | 56 +- server/src/ledgrab/static/locales/ru.json | 50 +- server/src/ledgrab/static/locales/zh.json | 50 +- server/src/ledgrab/templates/index.html | 1 + .../templates/modals/auto-calibration.html | 32 + .../ledgrab/templates/modals/calibration.html | 7 + 12 files changed, 1468 insertions(+), 22 deletions(-) create mode 100644 server/src/ledgrab/static/js/features/auto-calibration.ts create mode 100644 server/src/ledgrab/templates/modals/auto-calibration.html diff --git a/docs/CALIBRATION.md b/docs/CALIBRATION.md index ac40eda..9b230e9 100644 --- a/docs/CALIBRATION.md +++ b/docs/CALIBRATION.md @@ -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 diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 3154a7b..2a00cc7 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -2283,3 +2283,371 @@ 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; } +} + diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 97e518b..8eb13fe 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -203,12 +203,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 { @@ -620,6 +629,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, diff --git a/server/src/ledgrab/static/js/features/auto-calibration.ts b/server/src/ledgrab/static/js/features/auto-calibration.ts new file mode 100644 index 0000000..ca583c8 --- /dev/null +++ b/server/src/ledgrab/static/js/features/auto-calibration.ts @@ -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 { + 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 { + 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 = ` +
+
+ ${ICON_DEVICE} +
+
${_esc(t('autocal.device.title'))}
+
${_esc(t('autocal.device.desc'))}
+
+
+
+ + +
+ + +
`; + _populateDeviceSelect(); + _showError(_state?.errorMsg || ''); +} + +async function _populateDeviceSelect(): Promise { + 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[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 { + 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 = ` +
+
+ ${ICON_CALIBRATION} +
+
${_esc(t('autocal.corner.title'))}
+
${_esc(t('autocal.corner.desc'))}
+
+
+
+ + ${_esc(t('autocal.corner.led_index', { index: '0' }))} +
+
+ ${(['top_left', 'top_right', 'bottom_left', 'bottom_right'] as StartPosition[]).map(pos => + `` + ).join('')} +
+ + +
`; + _showError(s.errorMsg); +} + +export async function autoCalSetCorner(position: StartPosition): Promise { + 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 = ` +
+
+ ${ICON_ROTATE_CW} +
+
${_esc(t('autocal.direction.title', { step: String(advance) }))}
+
${_esc(t('autocal.direction.desc'))}
+
+
+
+ + +
+ + +
`; + _showError(_state.errorMsg); +} + +export async function autoCalSetDirection(layout: Layout): Promise { + 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 { + 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 `${i + 1}`; + }).join(''); + + const activeCornerLabel = isComplete ? '' : cornerLabels[collected - 1]; + + _opts.container.innerHTML = ` +
+
+ ${ICON_CALIBRATION} +
+
${_esc(isComplete ? t('autocal.corners.title', { remaining: '0' }) : t('autocal.corners.title', { remaining: String(4 - collected) }))}
+
${_esc( + isComplete + ? t('autocal.corners.desc_complete') + : t('autocal.corners.desc', { corner: activeCornerLabel }) + )}
+
+
+ +
+
${pips}
+
+ ${_esc(t('autocal.corners.index_label'))} + ${currentIndex} + / ${ledCount - 1} +
+
+ +
+ +
+
+
+
+ +
+ + ${isComplete ? '' : ` + `} + + + +
`; + _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 { + 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 { + 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 { + 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 { + 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 { + if (!_state || _state.busy || _state.cornerIndices.length !== 4) return; + _state.busy = true; + _state.errorMsg = ''; + _render(); + + try { + const solved = await apiPost('/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 = ` +
+
+ ${ICON_OK} +
+
${_esc(t('autocal.preview.title'))}
+
${_esc(t('autocal.preview.desc'))}
+
+
+ +
+
+ ${_esc(t('autocal.preview.start'))} + ${_esc(t(`autocal.position.${s.start_position}`))} +
+
+ ${_esc(t('calibration.direction'))} + ${dirIcon} ${_esc(dirLabel)} +
+
+ ${_esc(t('autocal.preview.top'))} + ${s.leds_top} +
+
+ ${_esc(t('autocal.preview.right'))} + ${s.leds_right} +
+
+ ${_esc(t('autocal.preview.bottom'))} + ${s.leds_bottom} +
+
+ ${_esc(t('autocal.preview.left'))} + ${s.leds_left} +
+
+ ${_esc(t('autocal.preview.total'))} + ${s.leds_top + s.leds_right + s.leds_bottom + s.leds_left} +
+
+ + + +
`; + _showError(_state.errorMsg); +} + +export async function autoCalSave(): Promise { + 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 { + const onCancel = _opts?.onCancel; + await unmountAutoCalibration(); + if (onCancel) onCancel(); +} + +// ── Session lifecycle ───────────────────────────────────────────────────── + +async function _startSession(): Promise { + if (!_state) return; + _state.busy = true; + _render(); + try { + const state = await apiPost('/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 { + if (!_state?.sessionActive) return; + try { + await apiPost('/calibration/session/stop', undefined, { + errorMessage: t('autocal.error.session_stop_failed'), + }); + } finally { + if (_state) _state.sessionActive = false; + } +} + +async function _setPosition(index: number): Promise { + if (!_state?.sessionActive) return; + await apiPost('/calibration/session/position', { + index, + window: 1, + }, { errorMessage: t('autocal.error.position_failed') }); +} + +// ── Utilities ───────────────────────────────────────────────────────────── + +function _delay(ms: number): Promise { + 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, '''); +} + +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 { + // 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 { + 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 { + await _autoCalModal.close(); +} diff --git a/server/src/ledgrab/static/js/features/calibration.ts b/server/src/ledgrab/static/js/features/calibration.ts index 6339a60..63545d3 100644 --- a/server/src/ledgrab/static/js/features/calibration.ts +++ b/server/src/ledgrab/static/js/features/calibration.ts @@ -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 { + 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) { diff --git a/server/src/ledgrab/static/js/global.d.ts b/server/src/ledgrab/static/js/global.d.ts index 29722e9..3e0ba42 100644 --- a/server/src/ledgrab/static/js/global.d.ts +++ b/server/src/ledgrab/static/js/global.d.ts @@ -354,6 +354,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; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index d541c44..6c5aba1 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -2593,9 +2593,9 @@ "automations.rule.home_assistant.state": "State:", "automations.rule.home_assistant.match_mode": "Match Mode:", "automations.rule.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state", - "automations.rule.ha.match_mode.exact.desc": "State must match exactly", - "automations.rule.ha.match_mode.contains.desc": "State must contain the text", - "automations.rule.ha.match_mode.regex.desc": "State must match the regex pattern", + "automations.rule.ha.match_mode.exact.desc": "State must match exactly", + "automations.rule.ha.match_mode.contains.desc": "State must contain the text", + "automations.rule.ha.match_mode.regex.desc": "State must match the regex pattern", "color_strip.clock": "Sync Clock:", "color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.", "graph.title": "Graph", @@ -2947,7 +2947,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 +3005,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 +3014,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 +3033,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 +3049,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 +3200,48 @@ "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." } diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index dea0fa3..b9d13a1 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -2629,7 +2629,6 @@ "donation.about_donate": "Поддержать разработку", "donation.about_license": "Лицензия MIT", "donation.about_author": "Создатель —", - "streams.group.game": "Игровая интеграция", "tree.group.game": "Игры", "game_integration.section_title": "Игровые интеграции", @@ -2688,7 +2687,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 +2696,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 +2715,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 +2731,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 +2882,48 @@ "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": "Калибровка успешно сохранена." } diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index df9e29f..9bd60bd 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -2623,7 +2623,6 @@ "donation.about_donate": "支持开发", "donation.about_license": "MIT 许可证", "donation.about_author": "作者:", - "streams.group.game": "游戏集成", "tree.group.game": "游戏", "game_integration.section_title": "游戏集成", @@ -2682,7 +2681,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 +2690,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 +2709,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 +2725,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 +2876,48 @@ "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": "校准已成功保存。" } diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html index 25e1f45..706c385 100644 --- a/server/src/ledgrab/templates/index.html +++ b/server/src/ledgrab/templates/index.html @@ -224,6 +224,7 @@ {% 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' %} diff --git a/server/src/ledgrab/templates/modals/auto-calibration.html b/server/src/ledgrab/templates/modals/auto-calibration.html new file mode 100644 index 0000000..1bdce4e --- /dev/null +++ b/server/src/ledgrab/templates/modals/auto-calibration.html @@ -0,0 +1,32 @@ + + diff --git a/server/src/ledgrab/templates/modals/calibration.html b/server/src/ledgrab/templates/modals/calibration.html index 7151587..2e45d5d 100644 --- a/server/src/ledgrab/templates/modals/calibration.html +++ b/server/src/ledgrab/templates/modals/calibration.html @@ -233,6 +233,13 @@ From abc204c04e4ec18660689f015c4eb005091fa025 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 8 Jun 2026 16:22:47 +0300 Subject: [PATCH 4/6] feat(snapshot): include scene playlists + cycling state in snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aggregated /api/v1/snapshot poll now emits a `scene_playlists` section (each playlist with its `is_running` flag) plus a companion `playlist_state` key carrying the single global cycling state (running playlist, current index/ preset, dwell) — so the HA-coordinator and other low-overhead pollers get playlist state in the same round trip as scenes/targets, matching the other entity sections. Gated by the `scene_playlists` include-section like the rest. Reuses the existing list_scene_playlists handler; snapshot route tests updated. --- server/src/ledgrab/api/routes/snapshot.py | 20 +++++++++++++++- .../tests/api/routes/test_snapshot_routes.py | 23 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/server/src/ledgrab/api/routes/snapshot.py b/server/src/ledgrab/api/routes/snapshot.py index 6b1d180..261e0d0 100644 --- a/server/src/ledgrab/api/routes/snapshot.py +++ b/server/src/ledgrab/api/routes/snapshot.py @@ -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 diff --git a/server/tests/api/routes/test_snapshot_routes.py b/server/tests/api/routes/test_snapshot_routes.py index a009bab..03fa8ae 100644 --- a/server/tests/api/routes/test_snapshot_routes.py +++ b/server/tests/api/routes/test_snapshot_routes.py @@ -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() From 81b18089e14e805550de53cf3fd358c9165b120b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 8 Jun 2026 16:27:55 +0300 Subject: [PATCH 5/6] feat(onboarding): guided first-run setup wizard (phase 4, final) A multi-step first-run wizard that takes a brand-new user from install to a running, calibrated ambient light in ~2 minutes, orchestrating the existing primitives (no node graph required). - features/setup-wizard.ts + modals/setup-wizard.html: welcome -> find device (discovery list + manual add via the canonical, URL-validated POST /devices) -> pick screen (GET /config/displays) -> scaffold (POST /setup/scaffold) -> calibrate (embeds the phase-3 auto-calibration flow via mountAutoCalibration on the scaffolded CSS + device) -> start -> done, with a progress indicator. - First-run trigger in app.ts (checkAndOpenWizardIfNeeded): on load, if the onboarding flag is unset AND no output targets exist, the wizard takes over and the tooltip tour is suppressed; on finish/skip it PUTs the onboarding flag and sets localStorage tour_completed so neither re-fires. Re-runnable. - tutorials.ts exposes TOUR_KEY + a takeover hook so the getting-started tour and the wizard never double-fire. - Calibrate step always calls unmountAutoCalibration() on exit so the device is restored. i18n in en/ru/zh (wizard.* keys + common.back). Final phase of the edge-calibration + first-run-wizard feature. Big Bang final gate green: tsc --noEmit clean, npm run build passes, full pytest suite 2064 passed / 2 skipped, ruff clean. --- server/src/ledgrab/static/css/components.css | 368 ++++++++ server/src/ledgrab/static/js/app.ts | 37 +- .../static/js/features/setup-wizard.ts | 811 ++++++++++++++++++ .../ledgrab/static/js/features/tutorials.ts | 14 +- server/src/ledgrab/static/js/global.d.ts | 15 + server/src/ledgrab/static/locales/en.json | 63 +- server/src/ledgrab/static/locales/ru.json | 63 +- server/src/ledgrab/static/locales/zh.json | 63 +- server/src/ledgrab/templates/index.html | 4 + .../templates/modals/setup-wizard.html | 30 + 10 files changed, 1462 insertions(+), 6 deletions(-) create mode 100644 server/src/ledgrab/static/js/features/setup-wizard.ts create mode 100644 server/src/ledgrab/templates/modals/setup-wizard.html diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 2a00cc7..a8b1466 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -2651,3 +2651,371 @@ textarea:focus-visible { .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; } +} + diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 8eb13fe..9c018d5 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -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 { @@ -329,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, @@ -951,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) { diff --git a/server/src/ledgrab/static/js/features/setup-wizard.ts b/server/src/ledgrab/static/js/features/setup-wizard.ts new file mode 100644 index 0000000..bb002e2 --- /dev/null +++ b/server/src/ledgrab/static/js/features/setup-wizard.ts @@ -0,0 +1,811 @@ +/** + * 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 { + 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 ONBOARDED_KEY = 'tour_completed'; // mirror tutorials.ts TOUR_KEY + +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 { + 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 { + try { + await apiPut('/preferences/onboarding', { onboarded: true }); + // Suppress tooltip tour too — wizard owns the first-run experience + localStorage.setItem(ONBOARDED_KEY, '1'); + } catch { + // Non-fatal: UI already moved on + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Wizard step navigation +// ───────────────────────────────────────────────────────────────────────────── + +function _stepIndex(step: WizardStep): number { + return STEPS.indexOf(step); +} + +export async function wizardNext(): Promise { + 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 { + 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 { + if (!_state || _state.busy) return; + _state.busy = true; + _state.errorMsg = ''; + _renderStep(); + try { + const body: Record = { + name, + device_type, + url, + led_count: 60, + }; + const device = await apiPost('/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 { + 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('/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 { + 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 { + if (!_state) return; + _state.busy = true; + _state.errorMsg = ''; + _renderStep(); + try { + const result = await apiPost('/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 { + if (!_state?.scaffoldResult) return; + _state.busy = true; + _state.errorMsg = ''; + _renderStep(); + try { + await apiPost(`/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 = ` +
+
+
+ `; + + 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 `${done ? ICON_CHECK : String(i + 1)}`; + }).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 `
+ + ${msg} +
`; +} + +function _buildWelcomeStep(): string { + return `
+
${ICON_SPARKLES}
+

${t('wizard.welcome.title')}

+

${t('wizard.welcome.desc')}

+
    +
  • ${ICON_DEVICE}${t('wizard.welcome.item1')}
  • +
  • ${ICON_MONITOR}${t('wizard.welcome.item2')}
  • +
  • ${ICON_CALIBRATION}${t('wizard.welcome.item3')}
  • +
  • ${ICON_START}${t('wizard.welcome.item4')}
  • +
+ +
`; +} + +function _buildDeviceStep(state: WizardState): string { + const existingDevices: Device[] = devicesCache.data || []; + + let discoveryHtml = ''; + if (state.busy && state.discoveredDevices.length === 0) { + discoveryHtml = `
+
+ ${t('wizard.device.scanning')} +
`; + } else if (state.discoveredDevices.length > 0) { + discoveryHtml = `
` + + state.discoveredDevices.map(d => ` + `).join('') + + `
`; + } else { + discoveryHtml = `
+ ${t('wizard.device.none_found')} +
`; + } + + let existingHtml = ''; + if (existingDevices.length > 0) { + existingHtml = ` +
` + + existingDevices.map(d => ` + `).join('') + + `
`; + } + + let manualHtml = ''; + if (state.manualMode) { + manualHtml = `
+
+ + +
+
+ + +
+
+ + +
+ ${_errorBanner(state.errorMsg)} + +
`; + } else { + manualHtml = ''; + } + + return `
+
+
${ICON_DEVICE}
+
+

${t('wizard.device.title')}

+

${t('wizard.device.desc')}

+
+
+ ${!state.manualMode ? ` +
+ + ${discoveryHtml} +
+ ${existingHtml} + ${_errorBanner(state.errorMsg)} + + ` : manualHtml} +
`; +} + +function _buildDisplayStep(state: WizardState): string { + const displays: Display[] = displaysCache.data ?? []; + + let listHtml = ''; + if (state.busy && displays.length === 0) { + listHtml = `
+
+ ${t('wizard.display.loading')} +
`; + } else if (displays.length === 0) { + // Fallback: offer a manual index input + listHtml = `
+

${t('wizard.display.no_displays')}

+
+ + +
+
`; + } else { + listHtml = `
` + + displays.map(d => { + const active = d.index === state.displayIndex; + return ``; + }).join('') + + `
`; + } + + return `
+
+
${ICON_MONITOR}
+
+

${t('wizard.display.title')}

+

${t('wizard.display.desc')}

+
+
+ ${listHtml} + ${_errorBanner(state.errorMsg)} + +
`; +} + +function _buildScaffoldStep(state: WizardState): string { + return `
+
+
${state.scaffoldResult ? ICON_OK : ICON_SPARKLES}
+
+

${t('wizard.scaffold.title')}

+

${state.busy ? t('wizard.scaffold.building') : state.scaffoldResult ? t('wizard.scaffold.done') : t('wizard.scaffold.desc')}

+
+
+ ${state.busy ? `
+
+ ${t('wizard.scaffold.building')} +
` : ''} + ${_errorBanner(state.errorMsg)} +
`; +} + +function _buildCalibrateStep(state: WizardState): string { + return `
+
+
${ICON_CALIBRATION}
+
+

${t('wizard.calibrate.title')}

+

${t('wizard.calibrate.desc')}

+
+
+ +
+ +
`; +} + +function _buildStartStep(state: WizardState): string { + return `
+
+
${START_STEP_ICON(state)}
+
+

${t('wizard.start.title')}

+

${state.busy ? t('wizard.start.starting') : state.errorMsg ? t('wizard.start.failed') : t('wizard.start.done')}

+
+
+ ${state.busy ? `
+
+ ${t('wizard.start.starting')} +
` : ''} + ${_errorBanner(state.errorMsg)} +
`; +} + +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 `
+
${ICON_ROCKET_ICON}
+

${t('wizard.done.title')}

+

${t('wizard.done.desc')}

+ ${state.scaffoldResult ? `
+
+ ${t('wizard.done.device')} + ${_esc(state.deviceName)} +
+
+ ${t('wizard.done.display')} + ${_esc(state.displayName || (t('wizard.display.index_prefix') + ' ' + String(state.displayIndex)))} +
+
` : ''} + +
`; +} + +function _attachStepListeners(step: WizardStep): void { + if (step === 'device') { + const form = document.getElementById('wizard-manual-form'); + if (form) form.addEventListener('submit', (e) => void wizardAddManualDevice(e)); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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, '''); +} diff --git a/server/src/ledgrab/static/js/features/tutorials.ts b/server/src/ledgrab/static/js/features/tutorials.ts index 99b27f7..117ad71 100644 --- a/server/src/ledgrab/static/js/features/tutorials.ts +++ b/server/src/ledgrab/static/js/features/tutorials.ts @@ -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' }, diff --git a/server/src/ledgrab/static/js/global.d.ts b/server/src/ledgrab/static/js/global.d.ts index 3e0ba42..4ff0677 100644 --- a/server/src/ledgrab/static/js/global.d.ts +++ b/server/src/ledgrab/static/js/global.d.ts @@ -60,6 +60,21 @@ interface Window { selectDisplay: (...args: any[]) => any; formatDisplayLabel: (...args: any[]) => any; + // ─── Setup Wizard ─── + openSetupWizard: () => void; + closeSetupWizard: () => void; + wizardNext: () => Promise; + wizardBack: () => void; + wizardSkip: () => void; + wizardFinish: () => void; + wizardShowManual: () => void; + wizardHideManual: () => void; + wizardRescan: () => void; + wizardSelectDiscovered: (url: string, name: string, device_type: string) => Promise; + wizardAddManualDevice: (event: Event) => Promise; + wizardUseExistingDevice: (deviceId: string, deviceName: string) => void; + wizardSelectDisplay: (index: number, displayName: string) => void; + // ─── Tutorials ─── startCalibrationTutorial: (...args: any[]) => any; startDeviceTutorial: (...args: any[]) => any; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 6c5aba1..33d6e27 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -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", @@ -3243,5 +3244,65 @@ "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." + "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." } diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index b9d13a1..4f5047c 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -731,6 +731,7 @@ "common.none_own_speed": "Нет (своя скорость)", "common.undo": "Отменить", "common.cancel": "Отмена", + "common.back": "Назад", "common.apply": "Применить", "common.start": "ПУСК", "common.stop": "СТОП", @@ -2925,5 +2926,65 @@ "autocal.error.solve_failed": "Не удалось вычислить калибровку.", "autocal.error.save_failed": "Не удалось сохранить калибровку.", "autocal.error.css_required": "Авто-калибровка требует источника цветовой полосы (не только устройства).", - "autocal.saved": "Калибровка успешно сохранена." + "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-вывод." } diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 9bd60bd..66097cb 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -727,6 +727,7 @@ "common.none_own_speed": "无(使用自身速度)", "common.undo": "撤销", "common.cancel": "取消", + "common.back": "返回", "common.apply": "应用", "common.start": "启动", "common.stop": "停止", @@ -2919,5 +2920,65 @@ "autocal.error.solve_failed": "校准求解失败。", "autocal.error.save_failed": "保存校准数据失败。", "autocal.error.css_required": "自动校准需要颜色灯带源(不支持纯设备目标)。", - "autocal.saved": "校准已成功保存。" + "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 输出失败。" } diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html index 706c385..e269142 100644 --- a/server/src/ledgrab/templates/index.html +++ b/server/src/ledgrab/templates/index.html @@ -75,6 +75,9 @@
API + @@ -222,6 +225,7 @@
+ {% include 'modals/setup-wizard.html' %} {% include 'modals/calibration.html' %} {% include 'modals/advanced-calibration.html' %} {% include 'modals/auto-calibration.html' %} diff --git a/server/src/ledgrab/templates/modals/setup-wizard.html b/server/src/ledgrab/templates/modals/setup-wizard.html new file mode 100644 index 0000000..965e428 --- /dev/null +++ b/server/src/ledgrab/templates/modals/setup-wizard.html @@ -0,0 +1,30 @@ + + From 6cd5e057da9b145c650217089b31409e24a60324 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 8 Jun 2026 16:55:36 +0300 Subject: [PATCH 6/6] fix(setup): register scaffolded target with ProcessorManager + final-review hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review blocker: the setup scaffold created the LED output target in the store but never registered it with the ProcessorManager, so the wizard's "Start" step 404'd on a fresh setup (target not found) — the lights never started despite a success screen. Now the scaffold calls target.register_with_manager(manager) right after create (mirroring the canonical POST /output-targets route, same ValueError guard), so start_processing finds the target. Rollback unregisters via manager.remove_target before deleting the store entity, so a post-registration failure leaves no half-registered target. Also from the final review: - solve corner_indices elements now bounded ge=0 (clear 422 instead of silent modulo-wrap). - setup-wizard.ts: reuse tutorials' suppressGettingStartedTour()/TOUR_KEY instead of a duplicated 'tour_completed' literal; drop a duplicate manual-form submit listener. Tests: + adversarial pass over the whole feature (solver/session/scaffold edge cases) and a scaffold->register->startable regression test. Full suite 2149 passed / 2 skipped; tsc clean; build passes; ruff clean. --- server/src/ledgrab/api/routes/setup.py | 31 + server/src/ledgrab/api/schemas/calibration.py | 7 +- .../static/js/features/setup-wizard.ts | 13 +- server/tests/api/routes/test_setup_routes.py | 88 ++- .../routes/test_setup_routes_adversarial.py | 506 ++++++++++++++++ .../test_calibration_session_adversarial.py | 535 +++++++++++++++++ .../test_calibration_solver_adversarial.py | 562 ++++++++++++++++++ 7 files changed, 1730 insertions(+), 12 deletions(-) create mode 100644 server/tests/api/routes/test_setup_routes_adversarial.py create mode 100644 server/tests/core/test_calibration_session_adversarial.py create mode 100644 server/tests/core/test_calibration_solver_adversarial.py diff --git a/server/src/ledgrab/api/routes/setup.py b/server/src/ledgrab/api/routes/setup.py index 0e3a947..2cd6983 100644 --- a/server/src/ledgrab/api/routes/setup.py +++ b/server/src/ledgrab/api/routes/setup.py @@ -36,11 +36,13 @@ from ledgrab.api.dependencies import ( 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 @@ -116,11 +118,16 @@ def _rollback( 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, @@ -129,6 +136,18 @@ def _rollback( "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) @@ -164,6 +183,7 @@ async def scaffold_setup( 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. @@ -192,6 +212,7 @@ async def scaffold_setup( picture_source_store=picture_source_store, css_store=css_store, output_target_store=output_target_store, + manager=manager, ) try: @@ -269,6 +290,16 @@ async def scaffold_setup( 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 diff --git a/server/src/ledgrab/api/schemas/calibration.py b/server/src/ledgrab/api/schemas/calibration.py index c13561c..ece3713 100644 --- a/server/src/ledgrab/api/schemas/calibration.py +++ b/server/src/ledgrab/api/schemas/calibration.py @@ -1,6 +1,6 @@ """Pydantic schemas for the calibration session and solver API.""" -from typing import List, Literal +from typing import Annotated, List, Literal from pydantic import BaseModel, Field, model_validator @@ -65,14 +65,15 @@ class CalibrationSolveRequest(BaseModel): layout: Literal["clockwise", "counterclockwise"] = Field( description="Winding direction of the strip" ) - corner_indices: List[int] = Field( + 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])." + "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, diff --git a/server/src/ledgrab/static/js/features/setup-wizard.ts b/server/src/ledgrab/static/js/features/setup-wizard.ts index bb002e2..94a8338 100644 --- a/server/src/ledgrab/static/js/features/setup-wizard.ts +++ b/server/src/ledgrab/static/js/features/setup-wizard.ts @@ -27,6 +27,7 @@ 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, @@ -76,8 +77,6 @@ interface WizardState { let _state: WizardState | null = null; let _modal: SetupWizardModal | null = null; -const ONBOARDED_KEY = 'tour_completed'; // mirror tutorials.ts TOUR_KEY - const STEPS: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start', 'done']; // ── Modal class ──────────────────────────────────────────────────────────────── @@ -167,7 +166,7 @@ async function _markOnboarded(): Promise { try { await apiPut('/preferences/onboarding', { onboarded: true }); // Suppress tooltip tour too — wizard owns the first-run experience - localStorage.setItem(ONBOARDED_KEY, '1'); + suppressGettingStartedTour(); } catch { // Non-fatal: UI already moved on } @@ -770,11 +769,9 @@ function _buildDoneStep(state: WizardState): string {
`; } -function _attachStepListeners(step: WizardStep): void { - if (step === 'device') { - const form = document.getElementById('wizard-manual-form'); - if (form) form.addEventListener('submit', (e) => void wizardAddManualDevice(e)); - } +function _attachStepListeners(_step: WizardStep): void { + // The manual device form uses onsubmit="wizardAddManualDevice(event)" inline — + // no duplicate addEventListener needed here. } // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/api/routes/test_setup_routes.py b/server/tests/api/routes/test_setup_routes.py index f5a51a8..31ab504 100644 --- a/server/tests/api/routes/test_setup_routes.py +++ b/server/tests/api/routes/test_setup_routes.py @@ -21,7 +21,7 @@ from __future__ import annotations import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from unittest.mock import patch +from unittest.mock import MagicMock, patch # --------------------------------------------------------------------------- @@ -89,6 +89,14 @@ def event_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, @@ -98,6 +106,7 @@ def setup_client( css_store, output_target_store, event_log, + mock_manager, ): from ledgrab.api.routes.setup import router from ledgrab.api.auth import verify_api_key @@ -111,6 +120,7 @@ def setup_client( 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): @@ -512,3 +522,79 @@ class TestScaffoldCalibrationIntegration: 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 diff --git a/server/tests/api/routes/test_setup_routes_adversarial.py b/server/tests/api/routes/test_setup_routes_adversarial.py new file mode 100644 index 0000000..0ce95f1 --- /dev/null +++ b/server/tests/api/routes/test_setup_routes_adversarial.py @@ -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" diff --git a/server/tests/core/test_calibration_session_adversarial.py b/server/tests/core/test_calibration_session_adversarial.py new file mode 100644 index 0000000..c0635cc --- /dev/null +++ b/server/tests/core/test_calibration_session_adversarial.py @@ -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 diff --git a/server/tests/core/test_calibration_solver_adversarial.py b/server/tests/core/test_calibration_solver_adversarial.py new file mode 100644 index 0000000..c1d291a --- /dev/null +++ b/server/tests/core/test_calibration_solver_adversarial.py @@ -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: end0 → 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)