obico.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. """API routes for Obico AI failure detection."""
  2. import logging
  3. from fastapi import APIRouter, Depends, HTTPException, Response
  4. from pydantic import BaseModel
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from backend.app.api.routes.settings import get_setting
  7. from backend.app.core.auth import ApiKeyActor, RequestActor, RequestPrinterScope, RequirePermissionIfAuthEnabled
  8. from backend.app.core.database import get_db
  9. from backend.app.core.permissions import Permission
  10. from backend.app.core.printer_scope import PrinterScope
  11. from backend.app.models.user import User
  12. from backend.app.services.obico_detection import obico_detection_service, pop_frame
  13. from backend.app.services.secret_fields import SECRET_PLACEHOLDER, same_value
  14. logger = logging.getLogger(__name__)
  15. router = APIRouter(prefix="/obico", tags=["obico"])
  16. class TestConnectionRequest(BaseModel):
  17. url: str
  18. # Omitted entirely or the placeholder = test with the saved token (against
  19. # the saved URL only); "" = test with no token.
  20. token: str | None = None
  21. @router.get("/status")
  22. async def get_status(
  23. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  24. ):
  25. """Scheduler status, per-printer classification, and recent detection history."""
  26. settings = await obico_detection_service._load_settings()
  27. status = obico_detection_service.get_status(settings["sensitivity"])
  28. return {
  29. **status,
  30. "enabled": settings["enabled"],
  31. "ml_url": settings["ml_url"],
  32. "sensitivity": settings["sensitivity"],
  33. "action": settings["action"],
  34. "poll_interval": settings["poll_interval"],
  35. "external_url_configured": bool(settings["snapshot_base_url"]),
  36. }
  37. @router.get("/printer-status")
  38. async def get_printer_status(
  39. user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  40. printer_scope: PrinterScope = RequestPrinterScope,
  41. actor: User | ApiKeyActor | None = RequestActor,
  42. ):
  43. """Per-printer live classification for the printer cards (#1546).
  44. Deliberately excludes configuration (ML URL, action, history) so users
  45. with printers:read but no settings:read can still render the badge.
  46. """
  47. settings = await obico_detection_service._load_settings()
  48. enabled_printers = settings["enabled_printers"]
  49. # Error strings can embed configured URLs (ML API base, external URL), so
  50. # they stay behind settings:read like the rest of the configuration.
  51. can_see_error = actor is None or actor.has_permission(Permission.SETTINGS_READ.value)
  52. per_printer = obico_detection_service.get_per_printer()
  53. if not can_see_error:
  54. # The "error" *class* is not configuration — a printers:read user still
  55. # needs to know their print is not being watched. Only the reason, which
  56. # can name a URL, is withheld.
  57. per_printer = {pid: {**entry, "error": None} for pid, entry in per_printer.items()}
  58. # Only printers the caller may see (#1727)
  59. per_printer = {pid: entry for pid, entry in per_printer.items() if printer_scope.allows(int(pid))}
  60. if enabled_printers is not None:
  61. enabled_printers = [pid for pid in enabled_printers if printer_scope.allows(int(pid))]
  62. return {
  63. "enabled": settings["enabled"],
  64. # None = all printers are monitored
  65. "monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
  66. "per_printer": per_printer,
  67. "last_error": obico_detection_service._last_error if can_see_error else None,
  68. }
  69. @router.post("/test-connection")
  70. async def test_connection(
  71. req: TestConnectionRequest,
  72. db: AsyncSession = Depends(get_db),
  73. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  74. ):
  75. """Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
  76. if not req.url:
  77. return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
  78. token = req.token
  79. if token is None or token == SECRET_PLACEHOLDER:
  80. # The saved token, which only goes to the saved URL.
  81. if not same_value(req.url, await get_setting(db, "obico_ml_url")):
  82. raise HTTPException(status_code=400, detail="Enter the token again: the URL changed")
  83. token = await get_setting(db, "obico_ml_token") or ""
  84. return await obico_detection_service.test_connection(req.url, token)
  85. @router.get("/cached-frame/{nonce}")
  86. async def cached_frame(nonce: str):
  87. """Serve a pre-captured JPEG to the Obico ML API.
  88. The detection loop captures a snapshot locally (where we control the timeout),
  89. stashes the bytes under a one-shot random nonce, then hands this URL to Obico's
  90. ML API. Obico's hardcoded 5s read timeout never races our snapshot pipeline.
  91. Unauthenticated: the unguessable 32-byte nonce is single-use and expires in
  92. seconds, so exposing this path doesn't widen the camera access surface.
  93. """
  94. data = await pop_frame(nonce)
  95. if data is None:
  96. raise HTTPException(status_code=404, detail="Frame not found or expired")
  97. return Response(
  98. content=data,
  99. media_type="image/jpeg",
  100. headers={"Cache-Control": "no-store"},
  101. )