feat(setup): one-call setup scaffold + onboarding flag (phase 2)
Backend for the first-run wizard (phase 4).
- POST /api/v1/setup/scaffold: given an existing device_id + display_index
(+ optional calibration), wires a working chain via the real validated
store create paths — create-or-reuse capture template -> raw picture
source -> picture color-strip source (calibration or default) -> LED
output target -> returns the ids. Does NOT auto-start. Rolls back every
entity it created (reverse order) on any partial failure, leaving no
orphans; "created" events are deferred until the whole chain succeeds so
a rolled-back scaffold never leaves ghost cards in the UI.
- Requires an existing device_id (no inline device creation) — the wizard
creates the device first via the canonical, URL-validated POST /devices,
so the scaffold can't bypass device validation. display_index is bounded.
- GET/PUT /api/v1/preferences/onboarding: persistent first-run flag
({onboarded, completed_at}) via db.set_setting; server stamps completed_at.
- Both routes AuthRequired. Tests: 25 (scaffold happy/reuse/rollback/
validation + onboarding + calibration round-trip integration). docs/API.md.
Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate
phase — full build/suite gated at the final phase).
This commit is contained in:
+68
-1
@@ -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`).
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -15,6 +15,7 @@ daylight value-source / color-strip-source. Stored as
|
||||
empty/missing meaning "use system local time".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
@@ -38,6 +39,7 @@ router = APIRouter()
|
||||
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
||||
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
||||
_CARD_MODES_KEY = "card_modes"
|
||||
_ONBOARDING_KEY = "onboarded"
|
||||
|
||||
|
||||
class DaylightTimezonePreference(BaseModel):
|
||||
@@ -285,4 +287,75 @@ async def put_daylight_timezone_preference(
|
||||
return DaylightTimezonePreference(timezone=saved)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Onboarding flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OnboardingPreference(BaseModel):
|
||||
"""Persistent first-run onboarding flag."""
|
||||
|
||||
onboarded: bool = Field(
|
||||
False,
|
||||
description="True once the user has completed the first-run wizard.",
|
||||
)
|
||||
completed_at: str | None = Field(
|
||||
None,
|
||||
description="ISO timestamp of when onboarding was first marked complete; null otherwise.",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/onboarding",
|
||||
response_model=OnboardingPreference,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_onboarding(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> OnboardingPreference:
|
||||
"""Return the first-run onboarding status.
|
||||
|
||||
Defaults to ``{onboarded: false, completed_at: null}`` when the flag has
|
||||
never been set.
|
||||
"""
|
||||
raw = db.get_setting(_ONBOARDING_KEY)
|
||||
if not raw:
|
||||
return OnboardingPreference()
|
||||
try:
|
||||
return OnboardingPreference.model_validate(raw)
|
||||
except Exception as exc:
|
||||
logger.warning("Stored onboarding preference invalid (%s); using default", exc)
|
||||
return OnboardingPreference()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/onboarding",
|
||||
response_model=OnboardingPreference,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_onboarding(
|
||||
_: AuthRequired,
|
||||
body: OnboardingPreference,
|
||||
db: Database = Depends(get_database),
|
||||
) -> OnboardingPreference:
|
||||
"""Persist the onboarding flag.
|
||||
|
||||
When ``onboarded`` is set to ``true`` and ``completed_at`` is not provided,
|
||||
the server stamps the current UTC time automatically.
|
||||
When ``onboarded`` is ``false``, ``completed_at`` is cleared.
|
||||
"""
|
||||
if body.onboarded and body.completed_at is None:
|
||||
body = OnboardingPreference(
|
||||
onboarded=True,
|
||||
completed_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
elif not body.onboarded:
|
||||
body = OnboardingPreference(onboarded=False, completed_at=None)
|
||||
|
||||
db.set_setting(_ONBOARDING_KEY, body.model_dump())
|
||||
logger.info("Onboarding flag updated: onboarded=%s", body.onboarded)
|
||||
return body
|
||||
|
||||
|
||||
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
)
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user