obico_detection.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. """Obico AI print-failure detection service.
  2. Polls a self-hosted Obico ML API with snapshots from each monitored printer
  3. while a print is running, smooths scores over time, and dispatches a configured
  4. action (notify / pause / pause_and_off) when a sustained failure is detected.
  5. See `obico_smoothing.py` for the per-print EWM + rolling-mean math.
  6. """
  7. import asyncio
  8. import json
  9. import logging
  10. import secrets
  11. import time
  12. from collections import deque
  13. from datetime import datetime, timezone
  14. import httpx
  15. from sqlalchemy import select
  16. from backend.app.core.database import async_session
  17. from backend.app.models.printer import Printer
  18. from backend.app.models.settings import Settings
  19. from backend.app.services.obico_smoothing import (
  20. PrintState,
  21. classify,
  22. score_from_detections,
  23. thresholds,
  24. )
  25. logger = logging.getLogger(__name__)
  26. HISTORY_MAX = 50
  27. HEALTH_TIMEOUT = 5.0
  28. DETECTION_TIMEOUT = 30.0
  29. SNAPSHOT_CAPTURE_TIMEOUT = 20 # seconds — we control this, not Obico
  30. FRAME_CACHE_TTL = 30.0 # seconds — Obico usually fetches within 1s of receiving the URL
  31. # Module-level one-shot frame cache. Obico's ML API is GET-only (/p/?img=URL) and
  32. # fetches the URL itself with a hardcoded 5s read timeout. We capture locally first,
  33. # stash the JPEG under a random nonce, and hand Obico a URL that serves the cached
  34. # bytes instantly — so the 5s ceiling never races RTSP keyframe wait.
  35. _frame_cache: dict[str, tuple[bytes, float]] = {}
  36. _frame_cache_lock = asyncio.Lock()
  37. def auth_headers(token: str | None) -> dict[str, str]:
  38. """Bearer header for the ML API, or nothing when no token is configured.
  39. Obico's ML API gates ``/p/`` behind ``ML_API_TOKEN`` (``ml_api/auth.py``):
  40. with the variable set it answers a bare 401 to any request whose
  41. ``Authorization`` header isn't ``Bearer <token>``, and with it unset it
  42. ignores the header entirely. Sending nothing when unconfigured keeps the
  43. request byte-identical to what shipped before the setting existed.
  44. """
  45. token = (token or "").strip()
  46. return {"Authorization": f"Bearer {token}"} if token else {}
  47. def _prune_frame_cache() -> None:
  48. """Drop entries older than FRAME_CACHE_TTL. Called under the cache lock."""
  49. now = time.monotonic()
  50. expired = [k for k, (_b, ts) in _frame_cache.items() if now - ts > FRAME_CACHE_TTL]
  51. for k in expired:
  52. _frame_cache.pop(k, None)
  53. async def stash_frame(data: bytes) -> str:
  54. """Store JPEG bytes and return a URL-safe nonce that serves them once."""
  55. nonce = secrets.token_urlsafe(32)
  56. async with _frame_cache_lock:
  57. _prune_frame_cache()
  58. _frame_cache[nonce] = (data, time.monotonic())
  59. return nonce
  60. async def pop_frame(nonce: str) -> bytes | None:
  61. """Return and remove a cached frame by nonce; None if missing or expired."""
  62. async with _frame_cache_lock:
  63. _prune_frame_cache()
  64. entry = _frame_cache.pop(nonce, None)
  65. if entry is None:
  66. return None
  67. data, ts = entry
  68. if time.monotonic() - ts > FRAME_CACHE_TTL:
  69. return None
  70. return data
  71. class ObicoDetectionService:
  72. """Singleton service that polls the ML API and acts on sustained failures."""
  73. def __init__(self):
  74. self._task: asyncio.Task | None = None
  75. # printer_id -> PrintState (reset when a new print starts)
  76. self._states: dict[int, PrintState] = {}
  77. # printer_id -> task_name active when state was created (used to detect new prints)
  78. self._state_keys: dict[int, str] = {}
  79. # printer_id -> last classification ("safe"/"warning"/"failure")
  80. self._last_class: dict[int, str] = {}
  81. # printer_id -> whether an action has already been fired for the current print
  82. self._action_fired: dict[int, bool] = {}
  83. # Global detection event log (most-recent-first)
  84. self._history: deque = deque(maxlen=HISTORY_MAX)
  85. self._last_error: str | None = None
  86. # ---- lifecycle ----
  87. async def start(self):
  88. if self._task is not None:
  89. return
  90. logger.info("Starting Obico detection service")
  91. self._task = asyncio.create_task(self._loop())
  92. def stop(self):
  93. if self._task:
  94. self._task.cancel()
  95. self._task = None
  96. logger.info("Stopped Obico detection service")
  97. # ---- settings ----
  98. async def _load_settings(self) -> dict:
  99. keys = [
  100. "obico_enabled",
  101. "obico_ml_url",
  102. "obico_ml_token",
  103. "obico_sensitivity",
  104. "obico_action",
  105. "obico_poll_interval",
  106. "obico_enabled_printers",
  107. "external_url",
  108. ]
  109. async with async_session() as db:
  110. result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
  111. rows = {r.key: r.value for r in result.scalars().all()}
  112. enabled_printers_raw = rows.get("obico_enabled_printers", "")
  113. if enabled_printers_raw:
  114. try:
  115. enabled_printers = set(json.loads(enabled_printers_raw))
  116. except json.JSONDecodeError:
  117. enabled_printers = set()
  118. else:
  119. enabled_printers = None # None = all printers
  120. return {
  121. "enabled": rows.get("obico_enabled", "false").lower() == "true",
  122. "ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
  123. "ml_token": (rows.get("obico_ml_token") or "").strip(),
  124. "sensitivity": rows.get("obico_sensitivity", "medium"),
  125. "action": rows.get("obico_action", "notify"),
  126. "poll_interval": int(rows.get("obico_poll_interval", "10")),
  127. "enabled_printers": enabled_printers,
  128. "external_url": (rows.get("external_url") or "").rstrip("/"),
  129. }
  130. # ---- main loop ----
  131. async def _loop(self):
  132. """Poll active printers while enabled. Adjusts interval from settings each cycle."""
  133. while True:
  134. try:
  135. settings = await self._load_settings()
  136. interval = max(5, settings.get("poll_interval", 10))
  137. if not settings["enabled"] or not settings["ml_url"]:
  138. await asyncio.sleep(interval)
  139. continue
  140. await self._poll_once(settings)
  141. await asyncio.sleep(interval)
  142. except asyncio.CancelledError:
  143. break
  144. except Exception as e:
  145. logger.error("Obico detection loop error: %s", e)
  146. self._last_error = str(e) or type(e).__name__
  147. await asyncio.sleep(30)
  148. async def _poll_once(self, settings: dict):
  149. # Late import to avoid cycles at module load time
  150. from backend.app.services.printer_manager import printer_manager
  151. statuses = printer_manager.get_all_statuses()
  152. for printer_id, status in list(statuses.items()):
  153. if settings["enabled_printers"] is not None and printer_id not in settings["enabled_printers"]:
  154. continue
  155. if not printer_manager.is_connected(printer_id):
  156. continue
  157. if not status or getattr(status, "state", None) != "RUNNING":
  158. # Reset state when not printing so the next print starts fresh
  159. self._states.pop(printer_id, None)
  160. self._state_keys.pop(printer_id, None)
  161. self._action_fired.pop(printer_id, None)
  162. continue
  163. await self._check_printer(printer_id, status, settings)
  164. async def _capture_frame(self, printer_id: int) -> bytes | None:
  165. """Capture one JPEG frame from the printer camera. Returns None on failure."""
  166. # Late import to avoid cycles at module load time
  167. from backend.app.services.camera import capture_camera_frame_bytes
  168. from backend.app.services.external_camera import capture_frame as capture_external_frame
  169. async with async_session() as db:
  170. printer = await db.get(Printer, printer_id)
  171. if printer is None:
  172. self._last_error = f"Printer {printer_id} not found"
  173. return None
  174. if printer.external_camera_enabled and printer.external_camera_url:
  175. # Same rule as the built-in branch below, which this used to skip:
  176. # an external camera is single-reader too, so polling while a viewer
  177. # is attached just fails (#2707).
  178. from backend.app.api.routes.camera import live_frame_for_capture
  179. defer, buffered = live_frame_for_capture(printer_id)
  180. if defer:
  181. if buffered:
  182. return buffered
  183. logger.info(
  184. "Obico: viewer attached for printer %s but buffer empty; "
  185. "skipping this poll to avoid competing camera handle (#2707)",
  186. printer_id,
  187. )
  188. return None
  189. return await capture_external_frame(
  190. printer.external_camera_url,
  191. printer.external_camera_type,
  192. timeout=SNAPSHOT_CAPTURE_TIMEOUT,
  193. snapshot_url=printer.external_camera_snapshot_url,
  194. )
  195. # Reuse the fan-out broadcaster's buffered frame when a viewer is
  196. # already watching — avoids opening a second concurrent RTSP socket
  197. # on printers that allow only one camera connection (e.g. X2D
  198. # firmware 01.01.00.00; see #1271). Buffered frame is <1s old while
  199. # a viewer is connected.
  200. #
  201. # When a viewer is attached but no frame is buffered yet (startup
  202. # race, mid-reconnect), we DELIBERATELY skip this poll cycle instead
  203. # of falling through to capture_camera_frame_bytes. Opening a fresh
  204. # RTSP/chamber socket would compete with the live viewer and kick
  205. # the fan-out connection on most firmwares — exactly the freeze
  206. # reported in #1348. The poll loop retries in ~10s.
  207. from backend.app.api.routes.camera import is_stream_active, try_get_active_buffered_frame
  208. if is_stream_active(printer_id):
  209. buffered = try_get_active_buffered_frame(printer_id)
  210. if buffered:
  211. return buffered
  212. logger.info(
  213. "Obico: viewer attached for printer %s but buffer empty; skipping this poll to avoid competing camera socket (#1348)",
  214. printer_id,
  215. )
  216. return None
  217. return await capture_camera_frame_bytes(
  218. ip_address=printer.ip_address,
  219. access_code=printer.access_code,
  220. model=printer.model,
  221. timeout=SNAPSHOT_CAPTURE_TIMEOUT,
  222. )
  223. async def _check_printer(self, printer_id: int, status, settings: dict):
  224. task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
  225. key = f"{task_name}"
  226. if self._state_keys.get(printer_id) != key:
  227. self._states[printer_id] = PrintState()
  228. self._state_keys[printer_id] = key
  229. self._action_fired[printer_id] = False
  230. # Capture locally first, then hand Obico a nonce URL that returns the
  231. # cached bytes instantly. Obico's ML API is GET-only (/p/?img=URL) with a
  232. # hardcoded 5s read timeout which would otherwise race our /camera/snapshot
  233. # keyframe wait.
  234. frame = await self._capture_frame(printer_id)
  235. if not frame:
  236. self._last_error = f"Failed to capture snapshot for printer {printer_id}"
  237. logger.warning(self._last_error)
  238. return
  239. external_url = settings.get("external_url") or ""
  240. if not external_url:
  241. self._last_error = (
  242. "external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
  243. "Set Settings → General → External URL."
  244. )
  245. logger.warning(self._last_error)
  246. return
  247. nonce = await stash_frame(frame)
  248. snapshot_url = f"{external_url}/api/v1/obico/cached-frame/{nonce}"
  249. ml_url = f"{settings['ml_url']}/p/"
  250. try:
  251. async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
  252. resp = await client.get(
  253. ml_url,
  254. params={"img": snapshot_url},
  255. headers=auth_headers(settings.get("ml_token")),
  256. )
  257. if resp.status_code == 401:
  258. # The server runs with ML_API_TOKEN set and rejected ours.
  259. # Say so plainly: the health endpoint is ungated, so "Test
  260. # Connection" passes against exactly this configuration and
  261. # a raw 401 gives the user nothing to act on (#2733).
  262. self._last_error = (
  263. "Obico ML API rejected the token (401). Set Settings → Failure Detection → "
  264. "ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN "
  265. "on the server."
  266. )
  267. logger.warning("%s (printer %s)", self._last_error, printer_id)
  268. return
  269. resp.raise_for_status()
  270. payload = resp.json()
  271. except Exception as e:
  272. detail = str(e) or type(e).__name__
  273. self._last_error = f"ML API call failed for printer {printer_id}: {detail}"
  274. logger.warning(self._last_error)
  275. return
  276. detections = payload.get("detections", []) if isinstance(payload, dict) else []
  277. current_p = score_from_detections(detections)
  278. state = self._states[printer_id]
  279. score = state.update(current_p)
  280. verdict = classify(score, settings["sensitivity"])
  281. self._last_class[printer_id] = verdict
  282. # A successful capture + ML call clears any transient error from previous
  283. # polls (typical case: cold-start RTSP timeout on first frame after startup,
  284. # followed by healthy polls that otherwise leave the banner stuck in the UI).
  285. self._last_error = None
  286. # Log every non-safe sample — safe samples would flood history
  287. if verdict != "safe" or detections:
  288. self._history.appendleft(
  289. {
  290. "printer_id": printer_id,
  291. "task_name": task_name,
  292. "timestamp": datetime.now(timezone.utc).isoformat(),
  293. "current_p": round(current_p, 4),
  294. "score": round(score, 4),
  295. "class": verdict,
  296. "detections": len(detections),
  297. }
  298. )
  299. if verdict == "failure" and not self._action_fired.get(printer_id):
  300. self._action_fired[printer_id] = True
  301. await self._dispatch_action(printer_id, settings["action"], task_name, score)
  302. async def _dispatch_action(self, printer_id: int, action: str, task_name: str, score: float):
  303. from backend.app.services.obico_actions import execute_action
  304. logger.warning(
  305. "Obico: failure detected on printer %s (task=%r score=%.3f) — action=%s",
  306. printer_id,
  307. task_name,
  308. score,
  309. action,
  310. )
  311. try:
  312. await execute_action(printer_id, action, task_name, score)
  313. except Exception as e:
  314. self._last_error = f"Action dispatch failed: {e or type(e).__name__}"
  315. logger.error(self._last_error)
  316. # ---- queries ----
  317. def get_per_printer(self) -> dict:
  318. """Live classification per actively monitored printer.
  319. Only printers with a running, monitored print have a state entry, so
  320. consumers get "show nothing" for idle printers for free.
  321. """
  322. return {
  323. pid: {
  324. "class": self._last_class.get(pid, "safe"),
  325. "frame_count": state.frame_count,
  326. "score": round(state.ewm_mean, 4),
  327. }
  328. for pid, state in self._states.items()
  329. }
  330. def get_status(self, sensitivity: str = "medium") -> dict:
  331. # Report the thresholds for the configured sensitivity, not a hardcoded
  332. # "medium" — otherwise the Status panel always shows the medium row
  333. # regardless of the user's selection (#1469). thresholds() falls back
  334. # to the medium multiplier for any unrecognized value.
  335. low, high = thresholds(sensitivity)
  336. return {
  337. "is_running": self._task is not None and not self._task.done(),
  338. "last_error": self._last_error,
  339. "per_printer": self.get_per_printer(),
  340. "thresholds": {"low": low, "high": high},
  341. "history": list(self._history),
  342. }
  343. async def test_connection(self, url: str, token: str = "") -> dict:
  344. """Ping the ML API and check the token. Returns {ok, status_code, body, error, auth_ok}.
  345. The stored ``obico_ml_url`` setting is validated at the schema layer,
  346. but this route takes its URL from the request body, so the same
  347. LAN-service policy has to be applied here or the guard is trivially
  348. sidestepped by testing a URL instead of saving it. The response body
  349. is returned to the caller (it is the health signal — the endpoint
  350. answers "ok"), which is exactly why the destination must be inside
  351. policy before the request is made.
  352. ``token`` is used verbatim — resolving "not supplied" to the saved
  353. setting is the route's job, so this stays a pure outbound call.
  354. Health alone cannot answer whether the token works, because Obico
  355. gates ``/p/`` but leaves ``/hc/`` open — which is how a token-protected
  356. server passed this test while every detection call came back 401
  357. (#2733). So a second, side-effect-free probe follows: ``/p/`` with no
  358. ``img`` parameter. The auth decorator runs before the handler, so 401
  359. means the token was rejected and 422 ("Invalid request params") means
  360. it was accepted. No inference work is done either way.
  361. """
  362. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  363. try:
  364. assert_safe_lan_service_url(url, label="Obico ML URL")
  365. except ValueError as exc:
  366. return {"ok": False, "status_code": None, "body": None, "error": str(exc), "auth_ok": None}
  367. headers = auth_headers(token)
  368. base = url.rstrip("/")
  369. try:
  370. async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
  371. resp = await client.get(f"{base}/hc/", headers=headers)
  372. body = resp.text.strip()
  373. healthy = resp.status_code == 200 and body.lower() == "ok"
  374. if not healthy:
  375. return {
  376. "ok": False,
  377. "status_code": resp.status_code,
  378. "body": body,
  379. "error": None,
  380. "auth_ok": None,
  381. }
  382. auth_ok: bool | None
  383. try:
  384. probe = await client.get(f"{base}/p/", headers=headers)
  385. auth_ok = probe.status_code != 401
  386. except Exception:
  387. # The health check already succeeded, so don't fail the
  388. # whole test on the probe — report the token as unknown.
  389. auth_ok = None
  390. except Exception as e:
  391. return {
  392. "ok": False,
  393. "status_code": None,
  394. "body": None,
  395. "error": str(e) or type(e).__name__,
  396. "auth_ok": None,
  397. }
  398. if auth_ok is False:
  399. return {
  400. "ok": False,
  401. "status_code": 401,
  402. "body": body,
  403. "error": (
  404. "The ML API is reachable but rejected the token. It runs with ML_API_TOKEN set — "
  405. "enter that value as the ML API Token, or clear ML_API_TOKEN on the server."
  406. ),
  407. "auth_ok": False,
  408. }
  409. return {"ok": True, "status_code": resp.status_code, "body": body, "error": None, "auth_ok": auth_ok}
  410. obico_detection_service = ObicoDetectionService()