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:
2026-06-08 15:22:04 +03:00
parent 0409cd8b66
commit 9dcd76d264
6 changed files with 1019 additions and 1 deletions
+2
View File
@@ -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"]
+299
View File
@@ -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,
)
+63
View File
@@ -0,0 +1,63 @@
"""Pydantic schemas for the setup scaffold endpoint."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class ScaffoldRequest(BaseModel):
"""Request body for ``POST /api/v1/setup/scaffold``.
Creates a full capture-to-output chain:
capture template → picture source → picture color-strip source → LED output target
``device_id`` must reference an existing, validated device (created via
``POST /api/v1/devices``). The wizard flow is: discover/create the device
via the canonical device endpoint first, then call scaffold with the
resulting ``device_id``.
"""
# ── Existing device (required) ────────────────────────────────────────────
device_id: str = Field(
description="ID of an existing device to wire into the chain.",
)
# ── Capture / picture source ──────────────────────────────────────────────
display_index: int = Field(
0,
ge=0,
le=63,
description="Index of the monitor to capture (0 = primary; max 63).",
)
# ── Optional calibration override ─────────────────────────────────────────
calibration: dict[str, Any] | None = Field(
None,
description=(
"Optional CalibrationConfig dict to use for the color-strip source. "
"When omitted, ``create_default_calibration(led_count)`` is used."
),
)
class ScaffoldResponse(BaseModel):
"""IDs of every entity created (or reused) by the scaffold.
``capture_template_reused`` is ``True`` when the scaffold matched an
existing template by engine type instead of creating a new one.
The device is always pre-existing (created via the canonical device endpoint
before calling scaffold).
"""
device_id: str = Field(description="Device id (pre-existing).")
capture_template_id: str = Field(description="Capture template id.")
picture_source_id: str = Field(description="Raw picture source id.")
color_strip_source_id: str = Field(description="Picture color-strip source id.")
output_target_id: str = Field(description="LED output target id.")
capture_template_reused: bool = Field(
False,
description="True when an existing matching capture template was reused.",
)