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:
@@ -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.
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user