25c613c5cb
- entity CRUD via fire_entity_event choke point (name resolved/sanitized; deletes pass name explicitly) - auth: failures + WS session establishment (no tokens logged); per-IP audit-record throttle - device: online/offline (health), discovered/lost (zeroconf), ADB connect/disconnect - capture/system: target start-stop, scenes, playlists, automations, backup/restore, update, restart, calibration, settings - security hardening: sanitize_display strips control/NUL/ANSI/newlines from untrusted strings; malformed-IPv6 origin guard - 129 instrumentation tests (incl. secret-leak, log-injection, throttle, best-effort) + autouse throttle-reset fixture
273 lines
9.7 KiB
Python
273 lines
9.7 KiB
Python
"""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.storage.activity_log import ActivityCategory, ActivitySeverity
|
|
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")
|
|
|
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
|
|
|
rec = get_module_recorder()
|
|
if rec is not None:
|
|
rec.record(
|
|
category=ActivityCategory.SYSTEM,
|
|
action="calibration.started",
|
|
severity=ActivitySeverity.INFO,
|
|
entity_type="device",
|
|
entity_id=body.device_id,
|
|
message=f"Calibration session started for device '{body.device_id}'",
|
|
)
|
|
|
|
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")
|
|
|
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
|
|
|
rec = get_module_recorder()
|
|
if rec is not None:
|
|
rec.record(
|
|
category=ActivityCategory.SYSTEM,
|
|
action="calibration.stopped",
|
|
severity=ActivitySeverity.INFO,
|
|
message="Calibration session stopped",
|
|
)
|
|
|
|
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")
|
|
|
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
|
|
|
rec = get_module_recorder()
|
|
if rec is not None:
|
|
rec.record(
|
|
category=ActivityCategory.SYSTEM,
|
|
action="calibration.cancelled",
|
|
severity=ActivitySeverity.INFO,
|
|
message="Calibration session cancelled",
|
|
)
|
|
|
|
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,
|
|
)
|