Files
bambuddy/backend/app/api/routes/obico.py
T
maziggy eae5359fbc feat(printers): show AI failure detection state on printer cards (#1546)
The live Obico classification was only visible under Settings ->
Failure Detection, so tracking how detection matched an ongoing print
meant flipping between the Printers screen and Settings.

Each printer card's badge row now shows an AI badge whenever detection
is enabled for that printer, like the other health badges: gray Idle
while no print is being watched, then green Safe, amber Warning, or
red Failure while a print is actively monitored. The tooltip carries
the current smoothed score; clicking jumps to the full detection
status and history in Settings. Printers excluded from the monitored
subset show no badge.

Served by a new lightweight /obico/printer-status endpoint readable
with printer permissions alone - it exposes only the enabled flag, the
monitored-printer set, and per-printer classification, keeping ML URL
and other configuration behind the existing settings-gated endpoint.
2026-07-28 11:37:36 +02:00

93 lines
3.4 KiB
Python

"""API routes for Obico AI failure detection."""
import logging
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.permissions import Permission
from backend.app.models.user import User
from backend.app.services.obico_detection import obico_detection_service, pop_frame
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/obico", tags=["obico"])
class TestConnectionRequest(BaseModel):
url: str
@router.get("/status")
async def get_status(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Scheduler status, per-printer classification, and recent detection history."""
settings = await obico_detection_service._load_settings()
status = obico_detection_service.get_status(settings["sensitivity"])
return {
**status,
"enabled": settings["enabled"],
"ml_url": settings["ml_url"],
"sensitivity": settings["sensitivity"],
"action": settings["action"],
"poll_interval": settings["poll_interval"],
"external_url_configured": bool(settings["external_url"]),
}
@router.get("/printer-status")
async def get_printer_status(
user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
):
"""Per-printer live classification for the printer cards (#1546).
Deliberately excludes configuration (ML URL, action, history) so users
with printers:read but no settings:read can still render the badge.
"""
settings = await obico_detection_service._load_settings()
enabled_printers = settings["enabled_printers"]
# Error strings can embed configured URLs (ML API base, external URL), so
# they stay behind settings:read like the rest of the configuration.
can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
return {
"enabled": settings["enabled"],
# None = all printers are monitored
"monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
"per_printer": obico_detection_service.get_per_printer(),
"last_error": obico_detection_service._last_error if can_see_error else None,
}
@router.post("/test-connection")
async def test_connection(
req: TestConnectionRequest,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Ping the Obico ML API `/hc/` health endpoint. Returns ok + raw body."""
if not req.url:
return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
return await obico_detection_service.test_connection(req.url)
@router.get("/cached-frame/{nonce}")
async def cached_frame(nonce: str):
"""Serve a pre-captured JPEG to the Obico ML API.
The detection loop captures a snapshot locally (where we control the timeout),
stashes the bytes under a one-shot random nonce, then hands this URL to Obico's
ML API. Obico's hardcoded 5s read timeout never races our snapshot pipeline.
Unauthenticated: the unguessable 32-byte nonce is single-use and expires in
seconds, so exposing this path doesn't widen the camera access surface.
"""
data = await pop_frame(nonce)
if data is None:
raise HTTPException(status_code=404, detail="Frame not found or expired")
return Response(
content=data,
media_type="image/jpeg",
headers={"Cache-Control": "no-store"},
)