fix(setup): register scaffolded target with ProcessorManager + final-review hardening

Final-review blocker: the setup scaffold created the LED output target in the
store but never registered it with the ProcessorManager, so the wizard's
"Start" step 404'd on a fresh setup (target not found) — the lights never
started despite a success screen. Now the scaffold calls
target.register_with_manager(manager) right after create (mirroring the
canonical POST /output-targets route, same ValueError guard), so
start_processing finds the target. Rollback unregisters via
manager.remove_target before deleting the store entity, so a post-registration
failure leaves no half-registered target.

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

Tests: + adversarial pass over the whole feature (solver/session/scaffold edge
cases) and a scaffold->register->startable regression test. Full suite
2149 passed / 2 skipped; tsc clean; build passes; ruff clean.
This commit is contained in:
2026-06-08 16:55:36 +03:00
parent 81b18089e1
commit 6cd5e057da
7 changed files with 1730 additions and 12 deletions
+31
View File
@@ -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
@@ -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,
@@ -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<void> {
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 {
</div>`;
}
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.
}
// ─────────────────────────────────────────────────────────────────────────────