obico_detection.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  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 EWM + rolling-mean math (Obico's own).
  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.settings import Settings
  18. from backend.app.services.obico_smoothing import (
  19. ESCALATING_FACTOR,
  20. Baseline,
  21. PrintState,
  22. classify,
  23. score_from_detections,
  24. thresholds,
  25. triggers_action,
  26. )
  27. logger = logging.getLogger(__name__)
  28. # A print in these states is over, or a new one is being prepared: the next
  29. # RUNNING one starts fresh. PAUSE (and any state not listed) keeps the print's
  30. # smoothing and its "action fired", so a resumed print isn't re-armed (#3459).
  31. # PREPARE/SLICING only come before a print, and catch a reprint of the same
  32. # file with no job id whose FINISH fell between two polls.
  33. _ENDED_STATES = ("FINISH", "FAILED", "IDLE", "PREPARE", "SLICING")
  34. HISTORY_MAX = 50
  35. HEALTH_TIMEOUT = 5.0
  36. DETECTION_TIMEOUT = 30.0
  37. SNAPSHOT_CAPTURE_TIMEOUT = 20 # seconds — we control this, not Obico
  38. FRAME_CACHE_TTL = 30.0 # seconds — Obico usually fetches within 1s of receiving the URL
  39. # Module-level one-shot frame cache. Obico's ML API is GET-only (/p/?img=URL) and
  40. # fetches the URL itself with a hardcoded 5s read timeout. We capture locally first,
  41. # stash the JPEG under a random nonce, and hand Obico a URL that serves the cached
  42. # bytes instantly — so the 5s ceiling never races RTSP keyframe wait.
  43. _frame_cache: dict[str, tuple[bytes, float]] = {}
  44. _frame_cache_lock = asyncio.Lock()
  45. def auth_headers(token: str | None) -> dict[str, str]:
  46. """Bearer header for the ML API, or nothing when no token is configured.
  47. Obico's ML API gates ``/p/`` behind ``ML_API_TOKEN`` (``ml_api/auth.py``):
  48. with the variable set it answers a bare 401 to any request whose
  49. ``Authorization`` header isn't ``Bearer <token>``, and with it unset it
  50. ignores the header entirely. Sending nothing when unconfigured keeps the
  51. request byte-identical to what shipped before the setting existed.
  52. """
  53. token = (token or "").strip()
  54. return {"Authorization": f"Bearer {token}"} if token else {}
  55. def _prune_frame_cache() -> None:
  56. """Drop entries older than FRAME_CACHE_TTL. Called under the cache lock."""
  57. now = time.monotonic()
  58. expired = [k for k, (_b, ts) in _frame_cache.items() if now - ts > FRAME_CACHE_TTL]
  59. for k in expired:
  60. _frame_cache.pop(k, None)
  61. async def stash_frame(data: bytes) -> str:
  62. """Store JPEG bytes and return a URL-safe nonce that serves them once."""
  63. nonce = secrets.token_urlsafe(32)
  64. async with _frame_cache_lock:
  65. _prune_frame_cache()
  66. _frame_cache[nonce] = (data, time.monotonic())
  67. return nonce
  68. async def pop_frame(nonce: str) -> bytes | None:
  69. """Return and remove a cached frame by nonce; None if missing or expired."""
  70. async with _frame_cache_lock:
  71. _prune_frame_cache()
  72. entry = _frame_cache.pop(nonce, None)
  73. if entry is None:
  74. return None
  75. data, ts = entry
  76. if time.monotonic() - ts > FRAME_CACHE_TTL:
  77. return None
  78. return data
  79. class ObicoDetectionService:
  80. """Singleton service that polls the ML API and acts on sustained failures."""
  81. def __init__(self):
  82. self._task: asyncio.Task | None = None
  83. # printer_id -> PrintState (reset when a new print starts)
  84. self._states: dict[int, PrintState] = {}
  85. # printer_id -> task_name active when state was created (used to detect new prints)
  86. self._state_keys: dict[int, str] = {}
  87. # printer_id -> the printer's job id (subtask_id) for that state, once known
  88. self._print_ids: dict[int, str] = {}
  89. # printer_id -> long-term mean of its frames, kept across its prints so
  90. # what its camera always sees counts as baseline (#3459). Memory only.
  91. self._baselines: dict[int, Baseline] = {}
  92. # printer_id -> last classification ("safe"/"warning"/"failure").
  93. # Only written after an inference actually came back, so a missing entry
  94. # means "we have no verdict", which is not the same as "safe" (#2952).
  95. self._last_class: dict[int, str] = {}
  96. # printer_id -> why the most recent poll produced no verdict, or absent
  97. # when the last poll succeeded. Per-printer rather than global so a card
  98. # can say what went wrong for *that* printer.
  99. self._errors: dict[int, str] = {}
  100. # printer_id -> whether an action has already been fired for the current print
  101. self._action_fired: dict[int, bool] = {}
  102. # Global detection event log (most-recent-first)
  103. self._history: deque = deque(maxlen=HISTORY_MAX)
  104. self._last_error: str | None = None
  105. def forget_printer(self, printer_id: int) -> None:
  106. """A deleted printer's verdict and score, not the next printer's given its id."""
  107. for state in (
  108. self._states,
  109. self._state_keys,
  110. self._print_ids,
  111. self._baselines,
  112. self._last_class,
  113. self._errors,
  114. self._action_fired,
  115. ):
  116. state.pop(printer_id, None)
  117. # ---- lifecycle ----
  118. async def start(self):
  119. if self._task is not None:
  120. return
  121. logger.info("Starting Obico detection service")
  122. self._task = asyncio.create_task(self._loop())
  123. def stop(self):
  124. if self._task:
  125. self._task.cancel()
  126. self._task = None
  127. logger.info("Stopped Obico detection service")
  128. async def refresh_settings(self):
  129. """Cancel an in-flight check when another provider takes over."""
  130. task = self._task
  131. self.stop()
  132. self._states.clear()
  133. self._state_keys.clear()
  134. self._print_ids.clear()
  135. self._action_fired.clear()
  136. self._last_class.clear()
  137. self._errors.clear()
  138. self._last_error = None
  139. if task is not None:
  140. try:
  141. await task
  142. except asyncio.CancelledError:
  143. pass
  144. await self.start()
  145. # ---- settings ----
  146. async def _load_settings(self) -> dict:
  147. keys = [
  148. "obico_enabled",
  149. "octoeverywhere_enabled",
  150. "obico_ml_url",
  151. "bambuddy_internal_url",
  152. "obico_ml_token",
  153. "obico_sensitivity",
  154. "obico_action",
  155. "obico_poll_interval",
  156. "obico_enabled_printers",
  157. "external_url",
  158. ]
  159. async with async_session() as db:
  160. result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
  161. rows = {r.key: r.value for r in result.scalars().all()}
  162. enabled_printers_raw = rows.get("obico_enabled_printers", "")
  163. if enabled_printers_raw:
  164. try:
  165. enabled_printers = set(json.loads(enabled_printers_raw))
  166. except json.JSONDecodeError:
  167. enabled_printers = set()
  168. else:
  169. enabled_printers = None # None = all printers
  170. return {
  171. # Restores can bypass PUT /settings's provider selection. A
  172. # conflicting configuration must never start both detectors.
  173. "enabled": rows.get("obico_enabled", "false").lower() == "true"
  174. and rows.get("octoeverywhere_enabled", "false").lower() != "true",
  175. "ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
  176. "ml_token": (rows.get("obico_ml_token") or "").strip(),
  177. "sensitivity": rows.get("obico_sensitivity", "medium"),
  178. "action": rows.get("obico_action", "notify"),
  179. "poll_interval": int(rows.get("obico_poll_interval", "10")),
  180. "enabled_printers": enabled_printers,
  181. # Where Obico's ML server fetches snapshots: the Internal URL when
  182. # set, otherwise the public External URL.
  183. "snapshot_base_url": (
  184. (rows.get("bambuddy_internal_url") or "").strip() or (rows.get("external_url") or "").strip()
  185. ).rstrip("/"),
  186. }
  187. # ---- main loop ----
  188. async def _loop(self):
  189. """Poll active printers while enabled. Adjusts interval from settings each cycle."""
  190. while True:
  191. try:
  192. settings = await self._load_settings()
  193. interval = max(5, settings.get("poll_interval", 10))
  194. if not settings["enabled"] or not settings["ml_url"]:
  195. await asyncio.sleep(interval)
  196. continue
  197. await self._poll_once(settings)
  198. await asyncio.sleep(interval)
  199. except asyncio.CancelledError:
  200. break
  201. except Exception as e:
  202. logger.error("Obico detection loop error: %s", e)
  203. self._last_error = str(e) or type(e).__name__
  204. await asyncio.sleep(30)
  205. async def _poll_once(self, settings: dict):
  206. # Late import to avoid cycles at module load time
  207. from backend.app.services.printer_manager import printer_manager
  208. statuses = printer_manager.get_all_statuses()
  209. for printer_id, status in list(statuses.items()):
  210. if settings["enabled_printers"] is not None and printer_id not in settings["enabled_printers"]:
  211. continue
  212. if not printer_manager.is_connected(printer_id):
  213. continue
  214. print_state = getattr(status, "state", None) if status else None
  215. if print_state != "RUNNING":
  216. if print_state is None or print_state in _ENDED_STATES:
  217. # The print is over: the next one starts fresh
  218. self._end_print(printer_id)
  219. continue
  220. await self._check_printer(printer_id, status, settings)
  221. def _end_print(self, printer_id: int) -> None:
  222. for state in (
  223. self._states,
  224. self._state_keys,
  225. self._print_ids,
  226. self._action_fired,
  227. self._last_class,
  228. self._errors,
  229. ):
  230. state.pop(printer_id, None)
  231. @staticmethod
  232. def _job_id(status) -> str | None:
  233. subtask_id = getattr(status, "subtask_id", None)
  234. if isinstance(subtask_id, (str, int)) and str(subtask_id) not in ("", "0"):
  235. return str(subtask_id)
  236. return None
  237. def _is_new_print(self, printer_id: int, key: str, job_id: str | None) -> bool:
  238. """Another task name, or another job id once both are known: a
  239. printer that went from one print to the next while we didn't see it
  240. idle (a pause no longer resets) must not carry the first one's state."""
  241. if self._state_keys.get(printer_id) != key:
  242. return True
  243. known = self._print_ids.get(printer_id)
  244. return bool(known and job_id and known != job_id)
  245. async def _capture_frame(self, printer_id: int) -> bytes | None:
  246. """Capture one JPEG frame from the printer camera. Returns None on failure."""
  247. from backend.app.services.failure_detection_camera import capture_detection_frame
  248. try:
  249. return await capture_detection_frame(
  250. printer_id, session_factory=async_session, timeout=SNAPSHOT_CAPTURE_TIMEOUT
  251. )
  252. except ValueError as e:
  253. self._last_error = str(e)
  254. return None
  255. def _no_verdict(self, printer_id: int, reason: str) -> None:
  256. """Record that this poll produced no verdict for ``printer_id``.
  257. Kept separate from the classification so the status surface can say
  258. "not checking" instead of inheriting the previous verdict — or, worse,
  259. the default "safe" a printer used to get before its first inference.
  260. """
  261. if printer_id not in self._states:
  262. return # deleted while the check waited (forget_printer)
  263. self._errors[printer_id] = reason
  264. self._last_error = reason
  265. logger.warning(reason)
  266. async def _check_printer(self, printer_id: int, status, settings: dict):
  267. task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
  268. key = f"{task_name}"
  269. job_id = self._job_id(status)
  270. if self._is_new_print(printer_id, key, job_id):
  271. baseline = self._baselines.setdefault(printer_id, Baseline())
  272. self._states[printer_id] = PrintState(baseline=baseline)
  273. self._state_keys[printer_id] = key
  274. self._print_ids.pop(printer_id, None)
  275. self._action_fired[printer_id] = False
  276. if job_id:
  277. self._print_ids[printer_id] = job_id
  278. # Capture locally first, then hand Obico a nonce URL that returns the
  279. # cached bytes instantly. Obico's ML API is GET-only (/p/?img=URL) with a
  280. # hardcoded 5s read timeout which would otherwise race our /camera/snapshot
  281. # keyframe wait.
  282. frame = await self._capture_frame(printer_id)
  283. if not frame:
  284. self._no_verdict(printer_id, f"Failed to capture snapshot for printer {printer_id}")
  285. return
  286. snapshot_base_url = settings.get("snapshot_base_url") or ""
  287. if not snapshot_base_url:
  288. self._no_verdict(
  289. printer_id,
  290. "bambuddy_internal_url and external_url settings are empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
  291. "Set Settings → Failure Detection → Bambuddy Internal URL or Settings → Network → External URL.",
  292. )
  293. return
  294. nonce = await stash_frame(frame)
  295. snapshot_url = f"{snapshot_base_url}/api/v1/obico/cached-frame/{nonce}"
  296. ml_url = f"{settings['ml_url']}/p/"
  297. try:
  298. async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
  299. resp = await client.get(
  300. ml_url,
  301. params={"img": snapshot_url},
  302. headers=auth_headers(settings.get("ml_token")),
  303. )
  304. if resp.status_code == 401:
  305. # The server runs with ML_API_TOKEN set and rejected ours.
  306. # Say so plainly: the health endpoint is ungated, so "Test
  307. # Connection" passes against exactly this configuration and
  308. # a raw 401 gives the user nothing to act on (#2733).
  309. #
  310. # Obico's auth decorator runs before the handler, so a call
  311. # rejected here leaves no trace in the ML API's own log —
  312. # which is how #2952 came to be reported as "the loop never
  313. # calls the ML API" while it was calling it every 10s.
  314. self._no_verdict(
  315. printer_id,
  316. "Obico ML API rejected the token (401). Set Settings → Failure Detection → "
  317. "ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN "
  318. "on the server.",
  319. )
  320. return
  321. resp.raise_for_status()
  322. payload = resp.json()
  323. except Exception as e:
  324. detail = str(e) or type(e).__name__
  325. self._no_verdict(printer_id, f"ML API call failed for printer {printer_id}: {detail}")
  326. return
  327. detections = payload.get("detections", []) if isinstance(payload, dict) else []
  328. current_p = score_from_detections(detections)
  329. state = self._states.get(printer_id)
  330. if state is None:
  331. return # deleted while the check waited (forget_printer)
  332. reading = state.update(current_p)
  333. score = reading.score
  334. verdict = classify(reading, settings["sensitivity"])
  335. self._last_class[printer_id] = verdict
  336. # A successful capture + ML call clears any transient error from previous
  337. # polls (typical case: cold-start RTSP timeout on first frame after startup,
  338. # followed by healthy polls that otherwise leave the banner stuck in the UI).
  339. self._errors.pop(printer_id, None)
  340. self._last_error = None
  341. # Log every non-safe sample — safe samples would flood history
  342. if verdict != "safe" or detections:
  343. self._history.appendleft(
  344. {
  345. "printer_id": printer_id,
  346. "task_name": task_name,
  347. "timestamp": datetime.now(timezone.utc).isoformat(),
  348. "current_p": round(current_p, 4),
  349. "score": round(max(0.0, score), 4),
  350. "class": verdict,
  351. "detections": len(detections),
  352. }
  353. )
  354. if triggers_action(verdict, settings["action"]) and not self._action_fired.get(printer_id):
  355. self._action_fired[printer_id] = True
  356. await self._dispatch_action(printer_id, settings["action"], task_name, score, frame)
  357. async def _dispatch_action(
  358. self, printer_id: int, action: str, task_name: str, score: float, frame: bytes | None = None
  359. ):
  360. from backend.app.services.obico_actions import execute_action
  361. logger.warning(
  362. "Obico: failure detected on printer %s (task=%r score=%.3f) — action=%s",
  363. printer_id,
  364. task_name,
  365. score,
  366. action,
  367. )
  368. try:
  369. # Same frame the ML model flagged, not a fresh capture — the
  370. # printer's moved on by the time this fires.
  371. await execute_action(printer_id, action, task_name, score, frame)
  372. except Exception as e:
  373. self._last_error = f"Action dispatch failed: {e or type(e).__name__}"
  374. logger.error(self._last_error)
  375. # ---- queries ----
  376. def get_per_printer(self) -> dict:
  377. """Live classification per actively monitored printer.
  378. Only printers with a running, monitored print have a state entry, so
  379. consumers get "show nothing" for idle printers for free.
  380. Four classes, and the two non-verdict ones matter as much as the rest:
  381. ``error`` the most recent poll produced no verdict. ``error`` carries
  382. the reason — a rejected token, an unreachable ML API, a
  383. camera that would not yield a frame, a missing Bambuddy address.
  384. ``unknown`` monitored, but no inference has come back yet. The state
  385. entry is created when the print is first seen, which is
  386. before the first capture, so this is the honest answer for
  387. that window.
  388. ``safe`` / ``warning`` / ``failure``
  389. an actual verdict from an actual inference.
  390. This used to default to ``safe`` whenever no verdict had been recorded,
  391. so a printer whose detection had never once succeeded rendered exactly
  392. like a healthy one: a green badge reading "Safe" at score 0.000. That is
  393. the worst possible failure mode for a safety feature — it asserts the
  394. print is being watched precisely when it is not (#2952).
  395. """
  396. result = {}
  397. for pid, state in self._states.items():
  398. error = self._errors.get(pid)
  399. if error:
  400. verdict = "error"
  401. else:
  402. verdict = self._last_class.get(pid) or "unknown"
  403. result[pid] = {
  404. "class": verdict,
  405. "frame_count": state.frame_count,
  406. # Below the printer's background is no detection at all.
  407. "score": round(max(0.0, state.last.score), 4),
  408. "error": error,
  409. }
  410. return result
  411. def get_status(self, sensitivity: str = "medium") -> dict:
  412. # Report the thresholds for the configured sensitivity, not a hardcoded
  413. # "medium" — otherwise the Status panel always shows the medium row
  414. # regardless of the user's selection (#1469). thresholds() falls back
  415. # to the medium multiplier for any unrecognized value.
  416. low, high = thresholds(sensitivity)
  417. return {
  418. "is_running": self._task is not None and not self._task.done(),
  419. "last_error": self._last_error,
  420. "per_printer": self.get_per_printer(),
  421. # Pausing needs the score divided by this factor to be failing.
  422. "thresholds": {"low": low, "high": high, "pause": high * ESCALATING_FACTOR},
  423. "history": list(self._history),
  424. }
  425. async def test_connection(self, url: str, token: str = "") -> dict:
  426. """Ping the ML API and check the token. Returns {ok, status_code, body, error, auth_ok}.
  427. The stored ``obico_ml_url`` setting is validated at the schema layer,
  428. but this route takes its URL from the request body, so the same
  429. LAN-service policy has to be applied here or the guard is trivially
  430. sidestepped by testing a URL instead of saving it. The response body
  431. is returned to the caller (it is the health signal — the endpoint
  432. answers "ok"), which is exactly why the destination must be inside
  433. policy before the request is made.
  434. ``token`` is used verbatim — resolving "not supplied" to the saved
  435. setting is the route's job, so this stays a pure outbound call.
  436. Health alone cannot answer whether the token works, because Obico
  437. gates ``/p/`` but leaves ``/hc/`` open — which is how a token-protected
  438. server passed this test while every detection call came back 401
  439. (#2733). So a second, side-effect-free probe follows: ``/p/`` with no
  440. ``img`` parameter. The auth decorator runs before the handler, so 401
  441. means the token was rejected and 422 ("Invalid request params") means
  442. it was accepted. No inference work is done either way.
  443. """
  444. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  445. try:
  446. assert_safe_lan_service_url(url, label="Obico ML URL")
  447. except ValueError as exc:
  448. return {"ok": False, "status_code": None, "body": None, "error": str(exc), "auth_ok": None}
  449. headers = auth_headers(token)
  450. base = url.rstrip("/")
  451. try:
  452. async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
  453. resp = await client.get(f"{base}/hc/", headers=headers)
  454. body = resp.text.strip()
  455. healthy = resp.status_code == 200 and body.lower() == "ok"
  456. if not healthy:
  457. return {
  458. "ok": False,
  459. "status_code": resp.status_code,
  460. "body": body,
  461. "error": None,
  462. "auth_ok": None,
  463. }
  464. auth_ok: bool | None
  465. try:
  466. probe = await client.get(f"{base}/p/", headers=headers)
  467. auth_ok = probe.status_code != 401
  468. except Exception:
  469. # The health check already succeeded, so don't fail the
  470. # whole test on the probe — report the token as unknown.
  471. auth_ok = None
  472. except Exception as e:
  473. return {
  474. "ok": False,
  475. "status_code": None,
  476. "body": None,
  477. "error": str(e) or type(e).__name__,
  478. "auth_ok": None,
  479. }
  480. if auth_ok is False:
  481. return {
  482. "ok": False,
  483. "status_code": 401,
  484. "body": body,
  485. "error": (
  486. "The ML API is reachable but rejected the token. It runs with ML_API_TOKEN set — "
  487. "enter that value as the ML API Token, or clear ML_API_TOKEN on the server."
  488. ),
  489. "auth_ok": False,
  490. }
  491. return {"ok": True, "status_code": resp.status_code, "body": body, "error": None, "auth_ok": auth_ok}
  492. obico_detection_service = ObicoDetectionService()