obico_detection.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  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 _prune_frame_cache() -> None:
  38. """Drop entries older than FRAME_CACHE_TTL. Called under the cache lock."""
  39. now = time.monotonic()
  40. expired = [k for k, (_b, ts) in _frame_cache.items() if now - ts > FRAME_CACHE_TTL]
  41. for k in expired:
  42. _frame_cache.pop(k, None)
  43. async def stash_frame(data: bytes) -> str:
  44. """Store JPEG bytes and return a URL-safe nonce that serves them once."""
  45. nonce = secrets.token_urlsafe(32)
  46. async with _frame_cache_lock:
  47. _prune_frame_cache()
  48. _frame_cache[nonce] = (data, time.monotonic())
  49. return nonce
  50. async def pop_frame(nonce: str) -> bytes | None:
  51. """Return and remove a cached frame by nonce; None if missing or expired."""
  52. async with _frame_cache_lock:
  53. _prune_frame_cache()
  54. entry = _frame_cache.pop(nonce, None)
  55. if entry is None:
  56. return None
  57. data, ts = entry
  58. if time.monotonic() - ts > FRAME_CACHE_TTL:
  59. return None
  60. return data
  61. class ObicoDetectionService:
  62. """Singleton service that polls the ML API and acts on sustained failures."""
  63. def __init__(self):
  64. self._task: asyncio.Task | None = None
  65. # printer_id -> PrintState (reset when a new print starts)
  66. self._states: dict[int, PrintState] = {}
  67. # printer_id -> task_name active when state was created (used to detect new prints)
  68. self._state_keys: dict[int, str] = {}
  69. # printer_id -> last classification ("safe"/"warning"/"failure")
  70. self._last_class: dict[int, str] = {}
  71. # printer_id -> whether an action has already been fired for the current print
  72. self._action_fired: dict[int, bool] = {}
  73. # Global detection event log (most-recent-first)
  74. self._history: deque = deque(maxlen=HISTORY_MAX)
  75. self._last_error: str | None = None
  76. # ---- lifecycle ----
  77. async def start(self):
  78. if self._task is not None:
  79. return
  80. logger.info("Starting Obico detection service")
  81. self._task = asyncio.create_task(self._loop())
  82. def stop(self):
  83. if self._task:
  84. self._task.cancel()
  85. self._task = None
  86. logger.info("Stopped Obico detection service")
  87. # ---- settings ----
  88. async def _load_settings(self) -> dict:
  89. keys = [
  90. "obico_enabled",
  91. "obico_ml_url",
  92. "obico_sensitivity",
  93. "obico_action",
  94. "obico_poll_interval",
  95. "obico_enabled_printers",
  96. "external_url",
  97. ]
  98. async with async_session() as db:
  99. result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
  100. rows = {r.key: r.value for r in result.scalars().all()}
  101. enabled_printers_raw = rows.get("obico_enabled_printers", "")
  102. if enabled_printers_raw:
  103. try:
  104. enabled_printers = set(json.loads(enabled_printers_raw))
  105. except json.JSONDecodeError:
  106. enabled_printers = set()
  107. else:
  108. enabled_printers = None # None = all printers
  109. return {
  110. "enabled": rows.get("obico_enabled", "false").lower() == "true",
  111. "ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
  112. "sensitivity": rows.get("obico_sensitivity", "medium"),
  113. "action": rows.get("obico_action", "notify"),
  114. "poll_interval": int(rows.get("obico_poll_interval", "10")),
  115. "enabled_printers": enabled_printers,
  116. "external_url": (rows.get("external_url") or "").rstrip("/"),
  117. }
  118. # ---- main loop ----
  119. async def _loop(self):
  120. """Poll active printers while enabled. Adjusts interval from settings each cycle."""
  121. while True:
  122. try:
  123. settings = await self._load_settings()
  124. interval = max(5, settings.get("poll_interval", 10))
  125. if not settings["enabled"] or not settings["ml_url"]:
  126. await asyncio.sleep(interval)
  127. continue
  128. await self._poll_once(settings)
  129. await asyncio.sleep(interval)
  130. except asyncio.CancelledError:
  131. break
  132. except Exception as e:
  133. logger.error("Obico detection loop error: %s", e)
  134. self._last_error = str(e) or type(e).__name__
  135. await asyncio.sleep(30)
  136. async def _poll_once(self, settings: dict):
  137. # Late import to avoid cycles at module load time
  138. from backend.app.services.printer_manager import printer_manager
  139. statuses = printer_manager.get_all_statuses()
  140. for printer_id, status in list(statuses.items()):
  141. if settings["enabled_printers"] is not None and printer_id not in settings["enabled_printers"]:
  142. continue
  143. if not printer_manager.is_connected(printer_id):
  144. continue
  145. if not status or getattr(status, "state", None) != "RUNNING":
  146. # Reset state when not printing so the next print starts fresh
  147. self._states.pop(printer_id, None)
  148. self._state_keys.pop(printer_id, None)
  149. self._action_fired.pop(printer_id, None)
  150. continue
  151. await self._check_printer(printer_id, status, settings)
  152. async def _capture_frame(self, printer_id: int) -> bytes | None:
  153. """Capture one JPEG frame from the printer camera. Returns None on failure."""
  154. # Late import to avoid cycles at module load time
  155. from backend.app.services.camera import capture_camera_frame_bytes
  156. from backend.app.services.external_camera import capture_frame as capture_external_frame
  157. async with async_session() as db:
  158. printer = await db.get(Printer, printer_id)
  159. if printer is None:
  160. self._last_error = f"Printer {printer_id} not found"
  161. return None
  162. if printer.external_camera_enabled and printer.external_camera_url:
  163. return await capture_external_frame(
  164. printer.external_camera_url,
  165. printer.external_camera_type,
  166. timeout=SNAPSHOT_CAPTURE_TIMEOUT,
  167. )
  168. return await capture_camera_frame_bytes(
  169. ip_address=printer.ip_address,
  170. access_code=printer.access_code,
  171. model=printer.model,
  172. timeout=SNAPSHOT_CAPTURE_TIMEOUT,
  173. )
  174. async def _check_printer(self, printer_id: int, status, settings: dict):
  175. task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
  176. key = f"{task_name}"
  177. if self._state_keys.get(printer_id) != key:
  178. self._states[printer_id] = PrintState()
  179. self._state_keys[printer_id] = key
  180. self._action_fired[printer_id] = False
  181. # Capture locally first, then hand Obico a nonce URL that returns the
  182. # cached bytes instantly. Obico's ML API is GET-only (/p/?img=URL) with a
  183. # hardcoded 5s read timeout which would otherwise race our /camera/snapshot
  184. # keyframe wait.
  185. frame = await self._capture_frame(printer_id)
  186. if not frame:
  187. self._last_error = f"Failed to capture snapshot for printer {printer_id}"
  188. logger.warning(self._last_error)
  189. return
  190. external_url = settings.get("external_url") or ""
  191. if not external_url:
  192. self._last_error = (
  193. "external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
  194. "Set Settings → General → External URL."
  195. )
  196. logger.warning(self._last_error)
  197. return
  198. nonce = await stash_frame(frame)
  199. snapshot_url = f"{external_url}/api/v1/obico/cached-frame/{nonce}"
  200. ml_url = f"{settings['ml_url']}/p/"
  201. try:
  202. async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
  203. resp = await client.get(ml_url, params={"img": snapshot_url})
  204. resp.raise_for_status()
  205. payload = resp.json()
  206. except Exception as e:
  207. detail = str(e) or type(e).__name__
  208. self._last_error = f"ML API call failed for printer {printer_id}: {detail}"
  209. logger.warning(self._last_error)
  210. return
  211. detections = payload.get("detections", []) if isinstance(payload, dict) else []
  212. current_p = score_from_detections(detections)
  213. state = self._states[printer_id]
  214. score = state.update(current_p)
  215. verdict = classify(score, settings["sensitivity"])
  216. self._last_class[printer_id] = verdict
  217. # Log every non-safe sample — safe samples would flood history
  218. if verdict != "safe" or detections:
  219. self._history.appendleft(
  220. {
  221. "printer_id": printer_id,
  222. "task_name": task_name,
  223. "timestamp": datetime.now(timezone.utc).isoformat(),
  224. "current_p": round(current_p, 4),
  225. "score": round(score, 4),
  226. "class": verdict,
  227. "detections": len(detections),
  228. }
  229. )
  230. if verdict == "failure" and not self._action_fired.get(printer_id):
  231. self._action_fired[printer_id] = True
  232. await self._dispatch_action(printer_id, settings["action"], task_name, score)
  233. async def _dispatch_action(self, printer_id: int, action: str, task_name: str, score: float):
  234. from backend.app.services.obico_actions import execute_action
  235. logger.warning(
  236. "Obico: failure detected on printer %s (task=%r score=%.3f) — action=%s",
  237. printer_id,
  238. task_name,
  239. score,
  240. action,
  241. )
  242. try:
  243. await execute_action(printer_id, action, task_name, score)
  244. except Exception as e:
  245. self._last_error = f"Action dispatch failed: {e or type(e).__name__}"
  246. logger.error(self._last_error)
  247. # ---- queries ----
  248. def get_status(self) -> dict:
  249. low, high = thresholds("medium")
  250. return {
  251. "is_running": self._task is not None and not self._task.done(),
  252. "last_error": self._last_error,
  253. "per_printer": {
  254. pid: {
  255. "class": self._last_class.get(pid, "safe"),
  256. "frame_count": state.frame_count,
  257. "score": round(state.ewm_mean, 4),
  258. }
  259. for pid, state in self._states.items()
  260. },
  261. "thresholds": {"low": low, "high": high},
  262. "history": list(self._history),
  263. }
  264. async def test_connection(self, url: str) -> dict:
  265. """Ping the ML API health endpoint. Returns {ok, status_code, body, error}."""
  266. target = f"{url.rstrip('/')}/hc/"
  267. try:
  268. async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
  269. resp = await client.get(target)
  270. body = resp.text.strip()
  271. return {
  272. "ok": resp.status_code == 200 and body.lower() == "ok",
  273. "status_code": resp.status_code,
  274. "body": body,
  275. "error": None,
  276. }
  277. except Exception as e:
  278. return {"ok": False, "status_code": None, "body": None, "error": str(e) or type(e).__name__}
  279. obico_detection_service = ObicoDetectionService()