Files
bambuddy/backend/app/api/routes/obico.py
T
maziggy a2c7fd4542 fix(obico): revert POST-bytes approach — Obico /p/ is GET-only
The 0.2.3b4 #1003 "fix" POSTed JPEG bytes as multipart form data,
  but Obico's /p/ endpoint is declared methods=['GET'] upstream and
  reads ?img=URL from the query string. Every POST was 405'd by
  Flask's router before any handler ran, which is why the Obico
  container logs were silent while Bambuddy kept reporting
  "ML API call failed for printer N:" with a blank suffix —
  raise_for_status() on the 405 produced an exception whose str()
  rendered empty.

  Restored the pre-#1003 nonce-URL approach (commit 3e434458):
  capture locally with a 20s timeout we control, stash the JPEG
  under a single-use 32-byte nonce, hand Obico a
  GET /api/v1/obico/cached-frame/{nonce} URL that resolves in
  <50ms so its hardcoded 5s read timeout never races RTSP.

  Also guards against future silent exceptions: the error format
  now falls back to type(exc).__name__ when str(exc) is empty.
  Detection also early-returns with an explicit error if
  external_url is unset instead of handing Obico a URL it can't
  resolve.

  The #1003 reverse-proxy scenario (Authelia/Authentik/CF Access
  in front of Bambuddy) is addressed by documenting that the
  /api/v1/obico/cached-frame/ path must be whitelisted from
  external auth at the proxy layer — it is already public on
  Bambuddy's side.

  Backend: services/obico_detection.py, api/routes/obico.py,
  main.py (PUBLIC_API_PATTERNS).
  Frontend: FailureDetectionSettings banner + client.ts type +
  all 7 locales restored.
  Tests: 15 unit + 5 integration tests pass.
2026-04-18 08:50:46 +02:00

70 lines
2.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()
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.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"},
)