feat(calibration): auto edge-calibration backend core (phase 1)

Backend engine for guided LED-chase calibration, driven by the upcoming
auto-calibration UI (phase 3) and first-run wizard (phase 4).

- solve_calibration(): pure function mapping start corner + direction + 4
  corner-tap indices to per-edge LED counts, consistent with EDGE_ORDER/
  EDGE_REVERSE so it round-trips through build_segments().
- CalibrationChaseMixin.set_calibration_pixel(): light a specific LED index
  (+ optional window) on a device, reusing the device_test_mode idle-client
  send path.
- CalibrationSession: single-active session with start/position/stop/cancel,
  a 60s idle-timeout watchdog, and a concurrency lock so interleaved calls
  can't corrupt the stop/restore bookkeeping — start() stops + remembers any
  running target on the device and stop/cancel/timeout always restore it
  (never leaves the device dark or stuck in chase).
- Routes /api/v1/calibration/{session,session/position,session/stop,
  session/cancel,session/state,solve} (all AuthRequired, bounds-validated);
  calibration is persisted by reusing the existing PUT /color-strip-sources/
  {id} (hot-reloads running streams) rather than a duplicate endpoint.
- Tests: 19 solver pure-logic + 19 route/bounds. docs/API.md updated.

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 14:59:58 +03:00
parent 6180569b10
commit 0409cd8b66
9 changed files with 1584 additions and 2 deletions
+2
View File
@@ -36,6 +36,7 @@ from .routes.pattern_templates import router as pattern_templates_router
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
router = APIRouter()
router.include_router(system_router)
@@ -72,5 +73,6 @@ router.include_router(pattern_templates_router)
router.include_router(preferences_router)
router.include_router(snapshot_router)
router.include_router(graph_router)
router.include_router(calibration_router)
__all__ = ["router"]
@@ -0,0 +1,236 @@
"""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.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")
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")
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")
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,
)
@@ -0,0 +1,103 @@
"""Pydantic schemas for the calibration session and solver API."""
from typing import List, Literal
from pydantic import BaseModel, Field, model_validator
# ── Session lifecycle ─────────────────────────────────────────────────────────
class CalibrationSessionStartRequest(BaseModel):
"""Request to start a calibration session on a device."""
device_id: str = Field(description="ID of the device to drive during calibration")
class CalibrationSessionPositionRequest(BaseModel):
"""Request to advance the chase pixel to a specific LED index."""
index: int = Field(ge=0, description="LED index to illuminate (0-based)")
window: int = Field(
default=1,
ge=0,
le=10,
description="Number of dim neighbour LEDs to show on each side (0 = centre only)",
)
class CalibrationSessionStateResponse(BaseModel):
"""Current calibration session state."""
active: bool = Field(description="Whether a session is currently active")
device_id: str | None = Field(None, description="Device being driven (null if inactive)")
led_count: int = Field(0, description="LED count of the active device")
prior_target_id: str | None = Field(
None, description="Target that was running before the session (will be restored on stop)"
)
last_activity: str | None = Field(
None, description="ISO timestamp of the last position call (null if inactive)"
)
# ── Solver ────────────────────────────────────────────────────────────────────
class CalibrationSolveRequest(BaseModel):
"""Request to solve a CalibrationConfig from 4 corner tap indices.
Provide either *device_id* (the server derives led_count from the device)
or *led_count* directly. *device_id* takes precedence.
"""
device_id: str | None = Field(
None,
description=("Device ID to derive led_count from (preferred over led_count field)"),
)
led_count: int | None = Field(
None,
ge=1,
description="Total LED count (used when device_id is not provided)",
)
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
description="Starting corner of the strip"
)
layout: Literal["clockwise", "counterclockwise"] = Field(
description="Winding direction of the strip"
)
corner_indices: List[int] = 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])."
),
min_length=4,
max_length=4,
)
offset: int = Field(
default=0,
ge=0,
description="Physical LED offset (0 = no offset)",
)
@model_validator(mode="after")
def _require_device_or_led_count(self) -> "CalibrationSolveRequest":
if self.device_id is None and self.led_count is None:
raise ValueError("Either 'device_id' or 'led_count' must be provided")
return self
class CalibrationSolvedResponse(BaseModel):
"""Solved calibration config in simple-mode dict form."""
mode: Literal["simple"] = "simple"
layout: str = Field(description="Winding direction")
start_position: str = Field(description="Starting corner")
leds_top: int = Field(ge=0, description="LEDs on the top edge")
leds_right: int = Field(ge=0, description="LEDs on the right edge")
leds_bottom: int = Field(ge=0, description="LEDs on the bottom edge")
leds_left: int = Field(ge=0, description="LEDs on the left edge")
offset: int = Field(ge=0, description="Physical LED offset")
@@ -668,6 +668,98 @@ def create_pixel_mapper(
return PixelMapper(calibration, interpolation_mode)
def solve_calibration(
led_count: int,
start_position: str,
layout: str,
corner_indices: List[int],
offset: int = 0,
) -> "CalibrationConfig":
"""Derive a CalibrationConfig from 4 corner tap indices.
Given the LED-strip indices where the user tapped each physical corner of
the screen (in strip-walk order matching *start_position* and *layout*),
compute per-edge LED counts that are consistent with
``EDGE_ORDER``/``EDGE_REVERSE`` and round-trip through
``build_segments()``.
Args:
led_count: Total number of LEDs on the strip.
start_position: Starting corner of the strip
(``"top_left"``, ``"top_right"``, ``"bottom_left"``,
``"bottom_right"``).
layout: Winding direction (``"clockwise"`` or
``"counterclockwise"``).
corner_indices: Four strip indices, one per screen corner, in the
same order as the strip walk defined by ``EDGE_ORDER`` for the
given *(start_position, layout)* pair. Index 0 is the start
corner, index 1 is the second corner reached while walking,
etc. Indices may wrap around (i.e. the last segment may
straddle the physical end of the strip).
offset: Physical LED offset stored directly on the config (0 = none).
Returns:
``CalibrationConfig`` in simple mode with per-edge counts filled in.
Raises:
ValueError: If *start_position*, *layout*, or the number of
corner indices is invalid.
"""
key = (start_position, layout)
if key not in EDGE_ORDER:
raise ValueError(
f"Invalid start_position/layout combination: {start_position!r}/{layout!r}"
)
if len(corner_indices) != 4:
raise ValueError(f"corner_indices must have exactly 4 entries, got {len(corner_indices)}")
if led_count <= 0:
raise ValueError(f"led_count must be positive, got {led_count}")
edge_order = EDGE_ORDER[key] # 4 edges in strip-walk order
# Compute per-edge LED counts from consecutive corner indices.
# The i-th edge spans from corner_indices[i] to corner_indices[(i+1) % 4],
# wrapping around led_count if necessary.
edge_counts: dict[str, int] = {}
for i, edge in enumerate(edge_order):
start_idx = corner_indices[i] % led_count
end_idx = corner_indices[(i + 1) % 4] % led_count
if end_idx > start_idx:
count = end_idx - start_idx
elif end_idx == start_idx:
# Adjacent taps on the same index → 0-LED edge
count = 0
else:
# Wrap-around: strip crosses the physical end
count = (led_count - start_idx) + end_idx
edge_counts[edge] = count
cfg = CalibrationConfig(
mode="simple",
layout=layout,
start_position=start_position,
leds_top=edge_counts.get("top", 0),
leds_right=edge_counts.get("right", 0),
leds_bottom=edge_counts.get("bottom", 0),
leds_left=edge_counts.get("left", 0),
offset=offset,
)
logger.info(
"solve_calibration: start=%s layout=%s corner_indices=%s "
"-> top=%d right=%d bottom=%d left=%d offset=%d",
start_position,
layout,
corner_indices,
cfg.leds_top,
cfg.leds_right,
cfg.leds_bottom,
cfg.leds_left,
offset,
)
return cfg
def create_default_calibration(
led_count: int,
aspect_width: int = 16,
@@ -0,0 +1,410 @@
"""Calibration session lifecycle and per-LED chase driver.
Provides two things:
1. ``set_calibration_pixel`` — direct per-index LED write for the chase
(added beside ``set_test_mode`` on ``ProcessorManager`` via the mixin, but
kept here to avoid growing device_test_mode.py further).
2. ``CalibrationSession`` — single-active-session guard with idle timeout and
guaranteed stop/restore contract.
Stop / restore contract (required by Phase 3 UI)
-------------------------------------------------
- ``start(device_id)``:
* If a target is currently processing on *device_id*, stop it and record
its ``target_id`` as ``_prior_target_id``.
* Send the device to black (chase start state).
* Record session as active with a fresh ``last_activity`` timestamp.
* Only one active session is allowed at a time; starting a new one on any
device while another is active calls ``stop()`` on the old one first.
- ``position(index, window)``:
* Validates ``index < led_count``; raises ``ValueError`` on out-of-range.
* Sends a chase pixel (bright white centre ±window dim neighbours).
* Updates ``last_activity``.
- ``stop()`` / ``cancel()``:
* Sends all-black to clear the device.
* If ``_prior_target_id`` was recorded, calls ``start_processing`` to
restart it.
* Clears the session state.
* NEVER leaves the device dark or stuck in chase.
- Idle timeout (``IDLE_TIMEOUT_SECONDS``, default 60 s):
* A background asyncio task checks ``last_activity``; if the session has
been idle longer than the timeout, ``stop()`` is called automatically.
* The timeout task is cancelled when ``stop()`` is called explicitly.
Notes
-----
- ``set_calibration_pixel`` reuses ``_get_idle_client`` /
``_send_pixels_to_device`` from ``DeviceTestModeMixin``; no new connection
management is needed.
- The session holds a reference to the ``ProcessorManager`` so it can call
``stop_processing`` / ``start_processing``.
- Thread-safety: all public methods are ``async``; the idle-timeout callback
schedules itself on the running event loop via ``asyncio.ensure_future``.
"""
import asyncio
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.processing.processor_manager import ProcessorManager
logger = get_logger(__name__)
# ── Constants ────────────────────────────────────────────────────────────────
IDLE_TIMEOUT_SECONDS: int = 60
"""Auto-stop a calibration session after this many seconds of inactivity."""
_CHASE_CENTER_COLOR: tuple[int, int, int] = (255, 255, 255)
"""Bright white for the chase centre pixel."""
_CHASE_WING_COLOR: tuple[int, int, int] = (60, 60, 60)
"""Dim grey for ±window neighbour pixels."""
# ── Mixin: per-index chase driver ────────────────────────────────────────────
class CalibrationChaseMixin:
"""Adds ``set_calibration_pixel`` to ``ProcessorManager``.
Requires the same host-class attributes as ``DeviceTestModeMixin``:
``_devices``, ``_processors``, ``_idle_clients``.
Inherits ``_send_pixels_to_device`` and ``_get_idle_client`` from
``DeviceTestModeMixin`` (both already on ``ProcessorManager``).
"""
async def set_calibration_pixel(
self,
device_id: str,
index: int,
color: tuple[int, int, int] = _CHASE_CENTER_COLOR,
window: int = 1,
) -> None:
"""Light a single LED index (plus optional ±window neighbours) on a device.
Sends a full pixel array to avoid partial-frame artefacts. The centre
LED is set to *color*; the ``window`` neighbours on each side are set to
``_CHASE_WING_COLOR`` (dim grey) so the user can see which direction the
strip is wound.
Args:
device_id: Target device ID (must be registered).
index: LED index to light (0-based). Must be < ``led_count``.
color: RGB tuple for the centre LED (default bright white).
window: Number of neighbouring LEDs to dim on each side (default 1).
Raises:
ValueError: If *device_id* is not registered or *index* is out of
range.
"""
if device_id not in self._devices:
raise ValueError(f"Device {device_id!r} not found")
ds = self._devices[device_id]
led_count = ds.led_count
if led_count <= 0:
raise ValueError(f"Device {device_id!r} has led_count={led_count}")
if not (0 <= index < led_count):
raise ValueError(
f"index {index} out of range for device {device_id!r} " f"(led_count={led_count})"
)
pixels: list[tuple[int, int, int]] = [(0, 0, 0)] * led_count
pixels[index] = color
for offset in range(1, window + 1):
left = (index - offset) % led_count
right = (index + offset) % led_count
pixels[left] = _CHASE_WING_COLOR
pixels[right] = _CHASE_WING_COLOR
# Re-assign center last so on tiny strips (window >= led_count) the
# center LED always shows the full color rather than a wrapped wing.
pixels[index] = color
await self._send_pixels_to_device(device_id, pixels)
logger.debug(
"set_calibration_pixel: device=%s index=%d window=%d",
device_id,
index,
window,
)
# ── Session lifecycle ─────────────────────────────────────────────────────────
class CalibrationSession:
"""Single-active calibration session with idle-timeout and stop/restore.
One instance is shared per application (singleton held by the API layer).
Only one session can be active at a time; starting a new session
automatically terminates the previous one.
All public methods that mutate session state acquire ``_lock`` so that
concurrent ``POST /session`` calls (or a ``stop`` racing with the idle
watchdog) cannot interleave and leave ``_prior_target_id`` stale. The
watchdog calls the internal ``_teardown_locked`` helper which must only be
invoked when the lock is already held; if the lock is already taken the
watchdog simply exits, letting the holder finish teardown.
"""
def __init__(self) -> None:
self._manager: "ProcessorManager | None" = None
self._device_id: str | None = None
self._led_count: int = 0
self._prior_target_id: str | None = None
self._last_activity: datetime | None = None
self._timeout_task: asyncio.Task | None = None
self._active: bool = False
self._lock: asyncio.Lock = asyncio.Lock()
# ── Public API ───────────────────────────────────────────────────────────
@property
def is_active(self) -> bool:
return self._active
@property
def device_id(self) -> str | None:
return self._device_id
@property
def led_count(self) -> int:
return self._led_count
@property
def last_activity(self) -> datetime | None:
return self._last_activity
async def start(self, device_id: str, manager: "ProcessorManager") -> None:
"""Begin a calibration session on *device_id*.
If a session is already active (even on a different device), it is
stopped first. If a target is currently processing on *device_id*, it
is stopped and remembered so it can be restored when this session ends.
Args:
device_id: The device to drive during calibration.
manager: Live ``ProcessorManager`` instance.
Raises:
ValueError: If *device_id* is not registered.
"""
async with self._lock:
# Validate device before touching any state or awaiting
if device_id not in manager._devices:
raise ValueError(f"Device {device_id!r} not found")
ds = manager._devices[device_id]
led_count = ds.led_count
# Capture the prior running target NOW — before any await — so the
# value cannot be mutated by a concurrent call that sneaks in after
# the lock is released between awaits.
prior_target_id = manager.get_processing_target_for_device(device_id)
# Terminate any existing session while we still hold the lock.
# Call _teardown_locked directly (we already hold the lock).
if self._active:
logger.info(
"CalibrationSession.start: stopping existing session on device=%s "
"to start new one on device=%s",
self._device_id,
device_id,
)
await self._teardown_locked(cancelled=False)
# Stop any running target on this device and remember it for restore
if prior_target_id is not None:
logger.info(
"CalibrationSession.start: stopping target %s on device %s for calibration",
prior_target_id,
device_id,
)
await manager.stop_processing(prior_target_id)
self._manager = manager
self._device_id = device_id
self._led_count = led_count
self._prior_target_id = prior_target_id
self._last_activity = datetime.now(timezone.utc)
self._active = True
# Clear the device to black so the chase starts from a clean state
await manager.send_clear_pixels(device_id)
# Start idle-timeout watchdog
self._timeout_task = asyncio.ensure_future(self._idle_watchdog())
logger.info(
"CalibrationSession.start: session started on device=%s led_count=%d "
"prior_target=%s",
device_id,
led_count,
prior_target_id,
)
async def position(self, index: int, window: int = 1) -> None:
"""Drive the chase pixel to *index* on the active device.
Args:
index: LED index to illuminate (0-based, must be < led_count).
window: Number of dim neighbours on each side (default 1).
Raises:
RuntimeError: If no session is active.
ValueError: If *index* is out of range.
"""
async with self._lock:
if not self._active or self._manager is None or self._device_id is None:
raise RuntimeError("No active calibration session")
if not (0 <= index < self._led_count):
raise ValueError(f"index {index} out of range (led_count={self._led_count})")
self._last_activity = datetime.now(timezone.utc)
await self._manager.set_calibration_pixel(self._device_id, index, window=window)
logger.debug(
"CalibrationSession.position: device=%s index=%d window=%d",
self._device_id,
index,
window,
)
async def stop(self) -> None:
"""End the session: clear the device and restore the prior target.
Safe to call even if no session is active (no-op).
"""
async with self._lock:
await self._teardown_locked(cancelled=False)
async def cancel(self) -> None:
"""Alias for ``stop()`` — ends the session without applying calibration."""
async with self._lock:
await self._teardown_locked(cancelled=True)
def get_state(self) -> dict:
"""Return a snapshot of the current session state for API responses."""
return {
"active": self._active,
"device_id": self._device_id,
"led_count": self._led_count,
"prior_target_id": self._prior_target_id,
"last_activity": (self._last_activity.isoformat() if self._last_activity else None),
}
# ── Internal ─────────────────────────────────────────────────────────────
async def _teardown_locked(self, cancelled: bool) -> None:
"""Clear the device, restore the prior target, and reset state.
MUST be called with ``self._lock`` already held by the caller.
Safe to call when already inactive (no-op).
"""
if not self._active:
return
device_id = self._device_id
manager = self._manager
prior_target_id = self._prior_target_id
# Cancel the idle watchdog — but only if we are NOT running inside it.
# Awaiting the current task would deadlock.
if (
self._timeout_task is not None
and self._timeout_task is not asyncio.current_task()
and not self._timeout_task.done()
):
self._timeout_task.cancel()
try:
await self._timeout_task
except asyncio.CancelledError:
pass
self._timeout_task = None
# Reset state before side-effects so re-entrant calls are no-ops
self._active = False
self._device_id = None
self._led_count = 0
self._prior_target_id = None
self._last_activity = None
self._manager = None
if manager is None or device_id is None:
return
# 1. Clear the device to black
try:
await manager.send_clear_pixels(device_id)
except Exception as exc:
logger.warning(
"CalibrationSession._teardown: failed to clear pixels on %s: %s",
device_id,
exc,
)
# 2. Restore the prior target (if any)
if prior_target_id is not None:
try:
await manager.start_processing(prior_target_id)
logger.info(
"CalibrationSession._teardown: restored target %s on device %s",
prior_target_id,
device_id,
)
except Exception as exc:
logger.error(
"CalibrationSession._teardown: failed to restore target %s on " "device %s: %s",
prior_target_id,
device_id,
exc,
)
action = "cancel" if cancelled else "stop"
logger.info(
"CalibrationSession.%s: session ended on device=%s prior_target=%s",
action,
device_id,
prior_target_id,
)
async def _idle_watchdog(self) -> None:
"""Background task: auto-stop the session after IDLE_TIMEOUT_SECONDS.
Tries to acquire ``_lock`` when the timeout fires. If the lock is
already held (e.g. a concurrent ``stop()`` is in progress) the
``acquire`` will wait; once it gets the lock, ``_teardown_locked``
is a no-op if the session was already ended by the other caller.
"""
try:
while True:
await asyncio.sleep(5)
if not self._active or self._last_activity is None:
break
elapsed = (datetime.now(timezone.utc) - self._last_activity).total_seconds()
if elapsed >= IDLE_TIMEOUT_SECONDS:
logger.warning(
"CalibrationSession._idle_watchdog: session on device=%s "
"idle for %.0fs — auto-stopping",
self._device_id,
elapsed,
)
async with self._lock:
await self._teardown_locked(cancelled=False)
break
except asyncio.CancelledError:
pass
# ── Module-level singleton ────────────────────────────────────────────────────
_session: CalibrationSession = CalibrationSession()
def get_calibration_session() -> CalibrationSession:
"""Return the module-level singleton ``CalibrationSession``."""
return _session
@@ -44,6 +44,7 @@ from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.core.processing.device_health import DeviceHealthMixin
from ledgrab.core.processing.device_test_mode import DeviceTestModeMixin
from ledgrab.core.capture.calibration_session import CalibrationChaseMixin
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -106,7 +107,9 @@ class DeviceState:
zone_mode: str = "combined"
class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin):
class ProcessorManager(
AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin, CalibrationChaseMixin
):
"""Manages devices and delegates target processing to TargetProcessor instances.
Devices are registered for health monitoring.