obico_detection.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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. from collections import deque
  11. from datetime import datetime, timedelta, timezone
  12. from urllib.parse import urlencode
  13. import httpx
  14. from sqlalchemy import select
  15. from backend.app.core.auth import (
  16. CAMERA_STREAM_TOKEN_EXPIRE_MINUTES,
  17. create_camera_stream_token,
  18. )
  19. from backend.app.core.database import async_session
  20. from backend.app.models.settings import Settings
  21. from backend.app.services.obico_smoothing import (
  22. PrintState,
  23. classify,
  24. score_from_detections,
  25. thresholds,
  26. )
  27. logger = logging.getLogger(__name__)
  28. HISTORY_MAX = 50
  29. HEALTH_TIMEOUT = 5.0
  30. DETECTION_TIMEOUT = 30.0
  31. class ObicoDetectionService:
  32. """Singleton service that polls the ML API and acts on sustained failures."""
  33. def __init__(self):
  34. self._task: asyncio.Task | None = None
  35. # printer_id -> PrintState (reset when a new print starts)
  36. self._states: dict[int, PrintState] = {}
  37. # printer_id -> task_name active when state was created (used to detect new prints)
  38. self._state_keys: dict[int, str] = {}
  39. # printer_id -> last classification ("safe"/"warning"/"failure")
  40. self._last_class: dict[int, str] = {}
  41. # printer_id -> whether an action has already been fired for the current print
  42. self._action_fired: dict[int, bool] = {}
  43. # Global detection event log (most-recent-first)
  44. self._history: deque = deque(maxlen=HISTORY_MAX)
  45. self._last_error: str | None = None
  46. # Cached camera-stream token so the ML API can fetch snapshots when
  47. # auth is enabled. Refreshed before expiry; harmless when auth is off.
  48. self._snapshot_token: str | None = None
  49. self._snapshot_token_expires_at: datetime | None = None
  50. # ---- lifecycle ----
  51. async def start(self):
  52. if self._task is not None:
  53. return
  54. logger.info("Starting Obico detection service")
  55. self._task = asyncio.create_task(self._loop())
  56. def stop(self):
  57. if self._task:
  58. self._task.cancel()
  59. self._task = None
  60. logger.info("Stopped Obico detection service")
  61. # ---- snapshot auth ----
  62. async def _get_snapshot_token(self) -> str:
  63. """Return a valid camera-stream token, refreshing it before expiry.
  64. The ML API fetches the snapshot URL directly, so when Bambuddy's auth
  65. is enabled the URL must carry a token (same scheme used by <img>-based
  66. camera consumers). When auth is disabled the token is simply ignored.
  67. """
  68. now = datetime.now(timezone.utc)
  69. refresh_before = timedelta(minutes=5)
  70. if (
  71. self._snapshot_token is None
  72. or self._snapshot_token_expires_at is None
  73. or self._snapshot_token_expires_at - now <= refresh_before
  74. ):
  75. self._snapshot_token = await create_camera_stream_token()
  76. self._snapshot_token_expires_at = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
  77. return self._snapshot_token
  78. # ---- settings ----
  79. async def _load_settings(self) -> dict:
  80. keys = [
  81. "obico_enabled",
  82. "obico_ml_url",
  83. "obico_sensitivity",
  84. "obico_action",
  85. "obico_poll_interval",
  86. "obico_enabled_printers",
  87. "external_url",
  88. ]
  89. async with async_session() as db:
  90. result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
  91. rows = {r.key: r.value for r in result.scalars().all()}
  92. enabled_printers_raw = rows.get("obico_enabled_printers", "")
  93. if enabled_printers_raw:
  94. try:
  95. enabled_printers = set(json.loads(enabled_printers_raw))
  96. except json.JSONDecodeError:
  97. enabled_printers = set()
  98. else:
  99. enabled_printers = None # None = all printers
  100. return {
  101. "enabled": rows.get("obico_enabled", "false").lower() == "true",
  102. "ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
  103. "sensitivity": rows.get("obico_sensitivity", "medium"),
  104. "action": rows.get("obico_action", "notify"),
  105. "poll_interval": int(rows.get("obico_poll_interval", "10")),
  106. "enabled_printers": enabled_printers,
  107. "external_url": (rows.get("external_url") or "").rstrip("/"),
  108. }
  109. # ---- main loop ----
  110. async def _loop(self):
  111. """Poll active printers while enabled. Adjusts interval from settings each cycle."""
  112. while True:
  113. try:
  114. settings = await self._load_settings()
  115. interval = max(5, settings.get("poll_interval", 10))
  116. if not settings["enabled"] or not settings["ml_url"]:
  117. await asyncio.sleep(interval)
  118. continue
  119. if not settings["external_url"]:
  120. # Without a reachable base URL, the ML API can't fetch snapshots.
  121. self._last_error = "external_url not set — ML API cannot reach snapshot endpoint"
  122. await asyncio.sleep(interval)
  123. continue
  124. await self._poll_once(settings)
  125. await asyncio.sleep(interval)
  126. except asyncio.CancelledError:
  127. break
  128. except Exception as e:
  129. logger.error("Obico detection loop error: %s", e)
  130. self._last_error = str(e)
  131. await asyncio.sleep(30)
  132. async def _poll_once(self, settings: dict):
  133. # Late import to avoid cycles at module load time
  134. from backend.app.services.printer_manager import printer_manager
  135. statuses = printer_manager.get_all_statuses()
  136. for printer_id, status in list(statuses.items()):
  137. if settings["enabled_printers"] is not None and printer_id not in settings["enabled_printers"]:
  138. continue
  139. if not printer_manager.is_connected(printer_id):
  140. continue
  141. if not status or getattr(status, "state", None) != "RUNNING":
  142. # Reset state when not printing so the next print starts fresh
  143. self._states.pop(printer_id, None)
  144. self._state_keys.pop(printer_id, None)
  145. self._action_fired.pop(printer_id, None)
  146. continue
  147. await self._check_printer(printer_id, status, settings)
  148. async def _check_printer(self, printer_id: int, status, settings: dict):
  149. task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
  150. key = f"{task_name}"
  151. if self._state_keys.get(printer_id) != key:
  152. self._states[printer_id] = PrintState()
  153. self._state_keys[printer_id] = key
  154. self._action_fired[printer_id] = False
  155. token = await self._get_snapshot_token()
  156. snapshot_url = (
  157. f"{settings['external_url']}/api/v1/printers/{printer_id}/camera/snapshot?{urlencode({'token': token})}"
  158. )
  159. ml_url = f"{settings['ml_url']}/p/"
  160. try:
  161. async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
  162. resp = await client.get(ml_url, params={"img": snapshot_url})
  163. resp.raise_for_status()
  164. payload = resp.json()
  165. except Exception as e:
  166. self._last_error = f"ML API call failed for printer {printer_id}: {e}"
  167. logger.warning(self._last_error)
  168. return
  169. detections = payload.get("detections", []) if isinstance(payload, dict) else []
  170. current_p = score_from_detections(detections)
  171. state = self._states[printer_id]
  172. score = state.update(current_p)
  173. verdict = classify(score, settings["sensitivity"])
  174. self._last_class[printer_id] = verdict
  175. # Log every non-safe sample — safe samples would flood history
  176. if verdict != "safe" or detections:
  177. self._history.appendleft(
  178. {
  179. "printer_id": printer_id,
  180. "task_name": task_name,
  181. "timestamp": datetime.now(timezone.utc).isoformat(),
  182. "current_p": round(current_p, 4),
  183. "score": round(score, 4),
  184. "class": verdict,
  185. "detections": len(detections),
  186. }
  187. )
  188. if verdict == "failure" and not self._action_fired.get(printer_id):
  189. self._action_fired[printer_id] = True
  190. await self._dispatch_action(printer_id, settings["action"], task_name, score)
  191. async def _dispatch_action(self, printer_id: int, action: str, task_name: str, score: float):
  192. from backend.app.services.obico_actions import execute_action
  193. logger.warning(
  194. "Obico: failure detected on printer %s (task=%r score=%.3f) — action=%s",
  195. printer_id,
  196. task_name,
  197. score,
  198. action,
  199. )
  200. try:
  201. await execute_action(printer_id, action, task_name, score)
  202. except Exception as e:
  203. self._last_error = f"Action dispatch failed: {e}"
  204. logger.error(self._last_error)
  205. # ---- queries ----
  206. def get_status(self) -> dict:
  207. low, high = thresholds("medium")
  208. return {
  209. "is_running": self._task is not None and not self._task.done(),
  210. "last_error": self._last_error,
  211. "per_printer": {
  212. pid: {
  213. "class": self._last_class.get(pid, "safe"),
  214. "frame_count": state.frame_count,
  215. "score": round(state.ewm_mean, 4),
  216. }
  217. for pid, state in self._states.items()
  218. },
  219. "thresholds": {"low": low, "high": high},
  220. "history": list(self._history),
  221. }
  222. async def test_connection(self, url: str) -> dict:
  223. """Ping the ML API health endpoint. Returns {ok, status_code, body, error}."""
  224. target = f"{url.rstrip('/')}/hc/"
  225. try:
  226. async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
  227. resp = await client.get(target)
  228. body = resp.text.strip()
  229. return {
  230. "ok": resp.status_code == 200 and body.lower() == "ok",
  231. "status_code": resp.status_code,
  232. "body": body,
  233. "error": None,
  234. }
  235. except Exception as e:
  236. return {"ok": False, "status_code": None, "body": None, "error": str(e)}
  237. obico_detection_service = ObicoDetectionService()