octoeverywhere_detection.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897
  1. """OctoEverywhere AI print-failure detection service.
  2. Each print owns a context so OctoEverywhere can combine camera snapshots over
  3. time. Snapshots follow the configured interval, sped up to the API's
  4. recommended interval while it suggests faster inspection, always respecting its
  5. minimum. The API decides when to warn or pause; raw model scores are not used
  6. for classification or actions.
  7. """
  8. import asyncio
  9. import json
  10. import logging
  11. import math
  12. import time
  13. from collections import deque
  14. from dataclasses import dataclass
  15. from datetime import datetime, timezone
  16. from email.utils import parsedate_to_datetime
  17. from functools import partial
  18. from urllib.parse import urlsplit
  19. import httpx
  20. from sqlalchemy import select
  21. from backend.app.core.database import async_session
  22. from backend.app.models.settings import Settings
  23. from backend.app.services.octoeverywhere_images import MAX_IMAGE_BYTES, prepare_frame
  24. logger = logging.getLogger(__name__)
  25. CREATE_CONTEXT_URL = "https://gadget-pv1-oeapi.octoeverywhere.com/api/gadget/v1/createcontext"
  26. DETECTION_TIMEOUT = 30.0
  27. POLL_INTERVAL = 5
  28. DEFAULT_POLL_INTERVAL = 20
  29. ERROR_RETRY_INTERVAL = 60
  30. NOTIFICATION_REPEAT_INTERVAL = 3 * 60 * 60
  31. HISTORY_MAX = 50
  32. # Gadget's confidence levels: 1 suggests a warning or pause soonest, with more
  33. # false positives; 5 waits until the model is most certain.
  34. CONFIDENCE_LEVELS = {"lowest": 1, "low": 2, "medium": 3, "high": 4, "highest": 5}
  35. IP_RESTRICTED_ERROR = "OE_API_KEY_IP_RESTRICTED"
  36. ACCOUNT_ERROR_CODES = {
  37. "OE_INVALID_API_KEY",
  38. "OE_API_KEY_DISABLED",
  39. "OE_API_KEY_BLOCKED_PAYMENT_FAILED",
  40. IP_RESTRICTED_ERROR,
  41. "OE_FREE_USAGE_LIMIT_REACHED",
  42. }
  43. API_ERRORS = {
  44. "OE_BAD_ARGS": "OctoEverywhere rejected the request arguments.",
  45. "OE_ARGS_PARSE_FAILED": "OctoEverywhere could not parse the request.",
  46. "OE_CONTEXT_RATE_LIMITED": "OctoEverywhere requested a delay before the next check. Detection will retry automatically.",
  47. "OE_IMAGE_DECODE_FAILED": "OctoEverywhere could not decode the camera snapshot.",
  48. "OE_INVALID_API_KEY": "OctoEverywhere rejected the Gadget API key. Check your API key in AI Failure Detection settings.",
  49. "OE_API_KEY_DISABLED": "The OctoEverywhere Gadget API key is disabled. Contact OctoEverywhere support to restore access.",
  50. "OE_API_KEY_BLOCKED_PAYMENT_FAILED": ("The OctoEverywhere API account is unavailable. Check the account status."),
  51. IP_RESTRICTED_ERROR: (
  52. "This IP address is already in use by another OctoEverywhere Gadget API key. "
  53. "Use the original key, or contact OctoEverywhere support."
  54. ),
  55. "OE_FREE_USAGE_LIMIT_REACHED": "API usage limit reached. Set up billing to continue.",
  56. "OE_BACKEND_THROTTLED": "OctoEverywhere is temporarily busy. Detection will retry automatically.",
  57. "OE_INTERNAL_ERROR": "OctoEverywhere encountered a service error. Detection will retry automatically.",
  58. }
  59. class _ApiError(Exception):
  60. """An actionable error with no remote response bodies or request secrets."""
  61. def __init__(
  62. self,
  63. message: str,
  64. status_code: int | None = None,
  65. retry_after: float = 0,
  66. *,
  67. context_missing: bool = False,
  68. error_code: str | None = None,
  69. ):
  70. super().__init__(message)
  71. self.status_code = status_code
  72. self.retry_after = retry_after
  73. self.context_missing = context_missing
  74. self.error_code = _known_error_code(error_code)
  75. @dataclass
  76. class _Context:
  77. context_id: str
  78. process_url: str
  79. fallback_url: str
  80. @dataclass
  81. class _PrintState:
  82. print_id: str | None = None
  83. context: _Context | None = None
  84. next_check_at: float = 0
  85. last_processed_at: float | None = None
  86. interval: int | None = None
  87. recommended_interval: int | None = None # Active only while faster inspections are suggested.
  88. frame_count: int = 0
  89. print_quality: int | None = None
  90. verdict: str = "unknown"
  91. error: str | None = None
  92. error_code: str | None = None
  93. failures: int = 0
  94. warning_fired: bool = False
  95. action_fired: bool = False
  96. last_notification_at: float | None = None
  97. def _process_interval(server_interval: int | None, poll_interval: int, recommended_interval: int | None = None) -> int:
  98. """Use the configured cadence, sped up by an active timing hint, respecting the minimum."""
  99. # A faster-inspection hint may only shorten the interval the user chose.
  100. interval = poll_interval if recommended_interval is None else min(recommended_interval, poll_interval)
  101. return max(server_interval or interval, interval)
  102. def _known_error_code(value) -> str | None:
  103. """Only expose documented, recognized codes, never arbitrary remote text."""
  104. return value if isinstance(value, str) and value in API_ERRORS else None
  105. def _process_url(value) -> str:
  106. """Only send the API key to HTTPS endpoints owned by OctoEverywhere."""
  107. if not isinstance(value, str):
  108. raise _ApiError("OctoEverywhere returned an invalid processing URL.")
  109. try:
  110. parsed = urlsplit(value)
  111. valid = (
  112. parsed.scheme == "https"
  113. and parsed.hostname is not None
  114. and parsed.hostname.endswith(".octoeverywhere.com")
  115. and parsed.port in (None, 443)
  116. and parsed.username is None
  117. and parsed.password is None
  118. and not parsed.fragment
  119. and not any(character.isspace() for character in value)
  120. )
  121. except ValueError:
  122. valid = False
  123. if not valid:
  124. raise _ApiError("OctoEverywhere returned an invalid processing URL.")
  125. return value
  126. def _retry_after(response: httpx.Response) -> float:
  127. value = response.headers.get("Retry-After", "")
  128. try:
  129. seconds = float(value)
  130. except ValueError:
  131. try:
  132. seconds = (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds()
  133. except (TypeError, ValueError, OverflowError):
  134. return 0
  135. return max(0, seconds) if math.isfinite(seconds) else 0
  136. def _response_payload(response: httpx.Response) -> dict:
  137. try:
  138. payload = response.json()
  139. except ValueError:
  140. payload = None
  141. if not 200 <= response.status_code < 300:
  142. error_type = payload.get("ErrorType") if isinstance(payload, dict) else None
  143. # The v1 server uses this specific 500 response for a missing context,
  144. # including one removed by its 14-day TTL. Other internal errors must
  145. # retain their context so a transient outage does not reset the model.
  146. context_missing = (
  147. response.status_code == 500
  148. and isinstance(payload, dict)
  149. and error_type == "OE_INTERNAL_ERROR"
  150. and payload.get("ErrorDetails") == "Failed to get context object."
  151. )
  152. message = API_ERRORS.get(error_type) if isinstance(error_type, str) else None
  153. if context_missing:
  154. message = "OctoEverywhere print context is no longer available. Detection will reconnect automatically."
  155. if not message:
  156. if response.status_code in (401, 403):
  157. message = API_ERRORS["OE_INVALID_API_KEY"]
  158. error_type = "OE_INVALID_API_KEY"
  159. elif response.status_code == 429:
  160. message = API_ERRORS["OE_CONTEXT_RATE_LIMITED"]
  161. else:
  162. message = f"OctoEverywhere API request failed (HTTP {response.status_code})."
  163. raise _ApiError(
  164. message,
  165. response.status_code,
  166. _retry_after(response),
  167. context_missing=context_missing,
  168. error_code=error_type,
  169. )
  170. if not isinstance(payload, dict):
  171. raise _ApiError("OctoEverywhere returned an invalid response.", response.status_code)
  172. return payload
  173. class OctoEverywhereDetectionService:
  174. """Singleton service that uploads snapshots from monitored, running prints."""
  175. def __init__(self):
  176. self._task: asyncio.Task | None = None
  177. self._running = False
  178. self._checks: dict[int, asyncio.Task] = {}
  179. self._actions: set[asyncio.Task] = set()
  180. self._states: dict[int, _PrintState] = {}
  181. self._configuration: tuple[str, str] | None = None
  182. self._settings: dict | None = None
  183. self._settings_refresh_at = 0.0
  184. self._generation = 0
  185. self._blocked_api_key: str | None = None
  186. self._blocked_error_code: str | None = None
  187. self._history: deque = deque(maxlen=HISTORY_MAX)
  188. self._last_error: str | None = None
  189. self._last_error_code: str | None = None
  190. def _set_last_error(self, reason: str | None, error_code: str | None = None):
  191. if self._blocked_error_code:
  192. reason = API_ERRORS[self._blocked_error_code]
  193. error_code = self._blocked_error_code
  194. self._last_error = reason
  195. self._last_error_code = _known_error_code(error_code) if reason else None
  196. def _refresh_last_error(self):
  197. state = next((state for state in self._states.values() if state.error), None)
  198. self._set_last_error(state.error if state else None, state.error_code if state else None)
  199. def _block_account(self, api_key: str, error_code: str):
  200. """Account failures stop every printer until the user explicitly retries."""
  201. self._blocked_api_key = api_key
  202. self._blocked_error_code = error_code
  203. self._generation += 1
  204. for task in self._checks.values():
  205. if task is not asyncio.current_task():
  206. task.cancel()
  207. self._refresh_last_error()
  208. logger.warning("OctoEverywhere inspections stopped: %s", API_ERRORS[error_code])
  209. def _clear_account_error(self):
  210. # Keep contexts, action tracking, and reserved server deadlines intact.
  211. self._blocked_api_key = None
  212. self._blocked_error_code = None
  213. self._refresh_last_error()
  214. async def start(self):
  215. self._running = True
  216. if self._task is not None and not self._task.done():
  217. return
  218. logger.info("Starting OctoEverywhere detection service")
  219. self._task = asyncio.create_task(self._loop())
  220. def stop(self):
  221. self._running = False
  222. if self._task:
  223. self._task.cancel()
  224. self._task = None
  225. logger.info("Stopped OctoEverywhere detection service")
  226. for task in self._checks.values():
  227. task.cancel()
  228. self._checks.clear()
  229. for task in self._actions:
  230. task.cancel()
  231. self._actions.clear()
  232. self._states.clear()
  233. self._generation += 1
  234. self._configuration = None
  235. self._settings = None
  236. self._clear_account_error()
  237. self._set_last_error(None)
  238. def reset_printer(self, printer_id: int):
  239. """Discard a completed print, including any inference still in flight."""
  240. task = self._checks.pop(printer_id, None)
  241. if task is not None and task is not asyncio.current_task():
  242. task.cancel()
  243. self._states.pop(printer_id, None)
  244. self._refresh_last_error()
  245. def forget_printer(self, printer_id: int) -> None:
  246. """Forget a deleted printer and reload its committed monitoring limits."""
  247. self.reset_printer(printer_id)
  248. self._history = deque(
  249. (entry for entry in self._history if entry["printer_id"] != printer_id), maxlen=HISTORY_MAX
  250. )
  251. self._generation += 1
  252. self._settings = None
  253. self._settings_refresh_at = 0
  254. # ---- settings ----
  255. async def refresh_settings(self):
  256. """Invalidate in-flight decisions immediately after settings are saved."""
  257. self._generation += 1
  258. self._settings = None
  259. generation = self._generation
  260. task = self._task
  261. self._task = None
  262. try:
  263. if task is not None:
  264. task.cancel()
  265. await asyncio.gather(task, return_exceptions=True)
  266. # Cancel stale inspections, but let already committed actions finish.
  267. checks = [task for task in self._checks.values() if task is not asyncio.current_task()]
  268. for task in checks:
  269. task.cancel()
  270. if checks:
  271. await asyncio.gather(*checks, return_exceptions=True)
  272. settings = await self._load_settings()
  273. if self._generation == generation:
  274. self._cache_settings(settings)
  275. self._apply_settings(settings)
  276. finally:
  277. # A failed/cancelled refresh must not permanently stop detection.
  278. # Lifecycle intent is separate from settings generations so stop()
  279. # during this refresh cannot accidentally resurrect the scheduler.
  280. if self._running and self._task is None:
  281. await self.start()
  282. def _cache_settings(self, settings: dict):
  283. self._settings = settings
  284. self._settings_refresh_at = time.monotonic() + settings["poll_interval"]
  285. def _apply_settings(self, settings: dict):
  286. configuration = (settings["api_key"], settings["confidence"])
  287. if self._configuration != configuration:
  288. self._generation += 1
  289. for task in self._checks.values():
  290. if task is not asyncio.current_task():
  291. task.cancel()
  292. if settings["api_key"] != self._blocked_api_key:
  293. self._clear_account_error()
  294. # A settings edit is still the same print. Replace its remote
  295. # context, but keep server pacing and actions already performed.
  296. for state in self._states.values():
  297. state.context = None
  298. state.recommended_interval = None
  299. state.frame_count = 0
  300. state.print_quality = None
  301. state.verdict = "unknown"
  302. state.error = None
  303. state.error_code = None
  304. state.failures = 0
  305. self._configuration = configuration
  306. self._set_last_error(None)
  307. if not settings["enabled"] or not settings["api_key"]:
  308. for printer_id in self._states.keys() | self._checks.keys():
  309. self.reset_printer(printer_id)
  310. self._set_last_error(None)
  311. if settings["enabled"] and not settings["api_key"]:
  312. self._set_last_error(
  313. "An OctoEverywhere Gadget API key is required. Add a key in Failure Detection settings."
  314. )
  315. if settings["enabled_printers"] is not None:
  316. for printer_id in self._states.keys() | self._checks.keys():
  317. if printer_id not in settings["enabled_printers"]:
  318. self.reset_printer(printer_id)
  319. async def _load_settings(self) -> dict:
  320. keys = [
  321. "obico_enabled",
  322. "octoeverywhere_enabled",
  323. "octoeverywhere_api_key",
  324. "octoeverywhere_confidence",
  325. "octoeverywhere_action",
  326. "octoeverywhere_enabled_printers",
  327. "octoeverywhere_poll_interval",
  328. ]
  329. async with async_session() as db:
  330. result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
  331. rows = {r.key: r.value for r in result.scalars().all()}
  332. enabled_printers_raw = rows.get("octoeverywhere_enabled_printers", "")
  333. enabled_printers = None
  334. if enabled_printers_raw:
  335. try:
  336. printer_ids = json.loads(enabled_printers_raw)
  337. enabled_printers = (
  338. set(printer_ids)
  339. if isinstance(printer_ids, list) and all(type(pid) is int for pid in printer_ids)
  340. else set()
  341. )
  342. except (TypeError, ValueError):
  343. enabled_printers = set()
  344. try:
  345. poll_interval = int(rows.get("octoeverywhere_poll_interval", DEFAULT_POLL_INTERVAL))
  346. if not 5 <= poll_interval <= 30:
  347. poll_interval = DEFAULT_POLL_INTERVAL
  348. except (TypeError, ValueError):
  349. poll_interval = DEFAULT_POLL_INTERVAL
  350. return {
  351. # Restores can bypass PUT /settings and leave both flags enabled.
  352. # Refuse to act while the other provider is enabled as well.
  353. "enabled": rows.get("octoeverywhere_enabled", "false").lower() == "true"
  354. and rows.get("obico_enabled", "false").lower() != "true",
  355. "api_key": (rows.get("octoeverywhere_api_key") or "").strip(),
  356. "confidence": rows.get("octoeverywhere_confidence", "medium"),
  357. "action": rows.get("octoeverywhere_action", "notify"),
  358. "enabled_printers": enabled_printers,
  359. "poll_interval": poll_interval,
  360. }
  361. # ---- main loop ----
  362. async def _loop(self):
  363. while True:
  364. try:
  365. # Printer deadlines need one-second resolution, but settings
  366. # only need the configured polling cadence or an explicit refresh.
  367. if self._settings is None or time.monotonic() >= self._settings_refresh_at:
  368. generation = self._generation
  369. settings = await self._load_settings()
  370. if generation != self._generation:
  371. continue
  372. self._cache_settings(settings)
  373. else:
  374. settings = self._settings
  375. await self._poll_once(settings)
  376. # Enabled printers need whole-second scheduling for values such
  377. # as 21 seconds; disabled detection can check settings less often.
  378. await asyncio.sleep(1 if settings["enabled"] else POLL_INTERVAL)
  379. except asyncio.CancelledError:
  380. break
  381. except Exception as exc:
  382. # Never include exception text: transport errors can contain
  383. # request URLs and user-supplied credentials.
  384. self._set_last_error("OctoEverywhere detection is temporarily unavailable. Retrying automatically.")
  385. logger.error("OctoEverywhere detection loop error: %s", type(exc).__name__)
  386. await asyncio.sleep(ERROR_RETRY_INTERVAL)
  387. async def _poll_once(self, settings: dict):
  388. from backend.app.services.printer_manager import printer_manager
  389. self._apply_settings(settings)
  390. if not settings["enabled"] or not settings["api_key"]:
  391. return
  392. statuses = printer_manager.get_all_statuses()
  393. for printer_id in self._states.keys() | self._checks.keys():
  394. if printer_id not in statuses:
  395. self.reset_printer(printer_id)
  396. for printer_id, status in list(statuses.items()):
  397. if settings["enabled_printers"] is not None and printer_id not in settings["enabled_printers"]:
  398. self.reset_printer(printer_id)
  399. continue
  400. if not printer_manager.is_connected(printer_id):
  401. task = self._checks.get(printer_id)
  402. if task:
  403. task.cancel()
  404. state = self._states.get(printer_id)
  405. if state:
  406. self._no_verdict(printer_id, state, "Printer is disconnected. Detection will resume on reconnect.")
  407. continue
  408. print_state = getattr(status, "state", None)
  409. if print_state in ("FINISH", "FAILED", "IDLE"):
  410. self.reset_printer(printer_id)
  411. continue
  412. # Unknown or transitional status is not evidence that a print ended.
  413. # Keep its context, but only upload while the printer is running.
  414. if print_state != "RUNNING":
  415. task = self._checks.get(printer_id)
  416. if task:
  417. task.cancel()
  418. continue
  419. state = self._states.get(printer_id)
  420. if state and not self._same_print(state, status):
  421. self.reset_printer(printer_id)
  422. task = self._checks.get(printer_id)
  423. if task is not None and not task.done():
  424. continue
  425. # One check per printer; a slow camera or API request must not
  426. # delay another printer's next inspection or lifecycle updates.
  427. task = asyncio.create_task(self._check_printer(printer_id, status, settings))
  428. self._checks[printer_id] = task
  429. task.add_done_callback(partial(self._check_done, printer_id))
  430. def _check_done(self, printer_id: int, task: asyncio.Task):
  431. # A canceled check may finish after its printer has started a new one.
  432. if self._checks.get(printer_id) is task:
  433. self._checks.pop(printer_id, None)
  434. if not task.cancelled():
  435. error = task.exception()
  436. if error is not None:
  437. logger.error("OctoEverywhere printer check failed: %s", type(error).__name__)
  438. @staticmethod
  439. def _print_id(status) -> str | None:
  440. subtask_id = getattr(status, "subtask_id", None)
  441. if subtask_id and str(subtask_id) != "0":
  442. return str(subtask_id)
  443. return None
  444. def _same_print(self, state: _PrintState, status) -> bool:
  445. """Learn late job IDs without discarding the context or forgetting a known ID."""
  446. print_id = self._print_id(status)
  447. if state.print_id and print_id and state.print_id != print_id:
  448. return False
  449. # Names and filenames may arrive late or change independently. Without
  450. # two distinct job IDs, rely on the print start/end callbacks instead.
  451. state.print_id = print_id or state.print_id
  452. return True
  453. def _is_current(self, printer_id: int, state: _PrintState, status, generation: int) -> bool:
  454. return (
  455. self._states.get(printer_id) is state
  456. and self._generation == generation
  457. and getattr(status, "state", None) == "RUNNING"
  458. and self._same_print(state, status)
  459. )
  460. async def _capture_frame(self, printer_id: int) -> bytes | None:
  461. from backend.app.services.failure_detection_camera import capture_detection_frame
  462. return await capture_detection_frame(printer_id)
  463. async def _create_context(self, client: httpx.AsyncClient, api_key: str, confidence: str) -> tuple[_Context, int]:
  464. level = CONFIDENCE_LEVELS.get(confidence, 3)
  465. response = await client.post(
  466. CREATE_CONTEXT_URL,
  467. headers={"X-API-Key": api_key},
  468. json={"WarningConfidenceLevel": level, "PauseConfidenceLevel": level},
  469. )
  470. payload = _response_payload(response)
  471. context_id = payload.get("ContextId")
  472. if not isinstance(context_id, str) or not context_id.strip():
  473. raise _ApiError("OctoEverywhere returned an invalid context.", response.status_code)
  474. return (
  475. _Context(
  476. context_id=context_id,
  477. process_url=_process_url(payload.get("ProcessRequestUrl")),
  478. fallback_url=_process_url(payload.get("FallbackProcessRequestUrl")),
  479. ),
  480. response.status_code,
  481. )
  482. def _no_verdict(self, printer_id: int, state: _PrintState, reason: str, error_code: str | None = None):
  483. if self._states.get(printer_id) is not state:
  484. return
  485. if state.error != reason:
  486. logger.warning("OctoEverywhere printer %s: %s", printer_id, reason)
  487. state.error = reason
  488. state.error_code = _known_error_code(error_code)
  489. self._set_last_error(reason, state.error_code)
  490. async def _check_printer(self, printer_id: int, status, settings: dict):
  491. generation = self._generation
  492. state = self._states.get(printer_id)
  493. if state is None or not self._same_print(state, status):
  494. state = _PrintState(print_id=self._print_id(status))
  495. self._states[printer_id] = state
  496. self._refresh_last_error()
  497. if self._blocked_error_code:
  498. return
  499. if state.last_processed_at is not None and not state.error:
  500. # Apply interval edits to an existing context on the next poll.
  501. # Failed/canceled requests clear last_processed_at, preserving their
  502. # reserved retry deadline regardless of the configured interval.
  503. state.next_check_at = state.last_processed_at + _process_interval(
  504. state.interval, settings["poll_interval"], state.recommended_interval
  505. )
  506. if time.monotonic() < state.next_check_at:
  507. return
  508. # Reserve this poll before awaiting camera/network I/O. Even a failed
  509. # capture must not create a hot loop or compete with a live camera.
  510. state.next_check_at = time.monotonic() + _process_interval(
  511. state.interval, settings["poll_interval"], state.recommended_interval
  512. )
  513. logger.debug(
  514. "OctoEverywhere inspection starting for printer %s (frame=%s, context_reused=%s)",
  515. printer_id,
  516. state.frame_count + 1,
  517. state.context is not None,
  518. )
  519. processing = False
  520. try:
  521. frame = await self._capture_frame(printer_id)
  522. if not frame:
  523. raise _ApiError("Could not capture a camera snapshot. Detection will retry automatically.")
  524. if len(frame) > MAX_IMAGE_BYTES:
  525. try:
  526. frame = await asyncio.to_thread(prepare_frame, frame)
  527. except ValueError:
  528. raise _ApiError("Could not reduce the camera snapshot to OctoEverywhere's 6 MiB image limit.")
  529. if not self._is_current(printer_id, state, status, generation):
  530. logger.debug(
  531. "OctoEverywhere inspection discarded for printer %s: print status or settings changed", printer_id
  532. )
  533. return
  534. async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT, follow_redirects=False) as client:
  535. if state.context is None:
  536. context, _ = await self._create_context(client, settings["api_key"], settings["confidence"])
  537. if not self._is_current(printer_id, state, status, generation):
  538. logger.debug(
  539. "OctoEverywhere inspection discarded for printer %s: print status or settings changed",
  540. printer_id,
  541. )
  542. return
  543. state.context = context
  544. logger.debug("OctoEverywhere context created for printer %s", printer_id)
  545. if not self._is_current(printer_id, state, status, generation):
  546. logger.debug(
  547. "OctoEverywhere inspection discarded for printer %s: print status or settings changed",
  548. printer_id,
  549. )
  550. return
  551. processing = True
  552. response = await client.post(
  553. state.context.process_url,
  554. headers={"X-API-Key": settings["api_key"]},
  555. files={"image": ("snapshot.jpg", frame, "image/jpeg")},
  556. )
  557. payload = _response_payload(response)
  558. interval = payload.get("NextProcessIntervalSec")
  559. if not isinstance(interval, dict):
  560. raise _ApiError("OctoEverywhere returned an invalid processing interval.")
  561. minimum_interval = interval.get("Minimum")
  562. if type(minimum_interval) is int and minimum_interval > 0:
  563. # Preserve the latest minimum even if the assessment is
  564. # invalid or canceled before its timing hint is accepted.
  565. state.interval = minimum_interval
  566. state.next_check_at = time.monotonic() + _process_interval(
  567. minimum_interval, settings["poll_interval"], state.recommended_interval
  568. )
  569. else:
  570. raise _ApiError("OctoEverywhere returned an invalid processing interval.")
  571. faster_inspection_suggested = payload.get("FasterInspectionSuggested") is True
  572. recommended_interval = interval.get("Recommended") if faster_inspection_suggested else None
  573. # This hint is optional. Older responses and malformed hints
  574. # keep fixed timing without discarding a valid assessment.
  575. if type(recommended_interval) is not int or recommended_interval <= 0:
  576. recommended_interval = None
  577. print_quality = payload.get("PrintQuality")
  578. warning = payload.get("WarningSuggested")
  579. pause = payload.get("PauseSuggested")
  580. if (
  581. type(print_quality) is not int
  582. or not 1 <= print_quality <= 10
  583. or type(warning) is not bool
  584. or type(pause) is not bool
  585. ):
  586. raise _ApiError("OctoEverywhere returned an invalid print assessment.")
  587. except asyncio.CancelledError:
  588. # A request canceled during a settings edit may already have been
  589. # processed remotely. Preserve the previous minimum before retrying.
  590. state.next_check_at = max(
  591. state.next_check_at,
  592. time.monotonic()
  593. + _process_interval(state.interval, settings["poll_interval"], state.recommended_interval),
  594. )
  595. state.last_processed_at = None
  596. logger.debug("OctoEverywhere inspection canceled for printer %s", printer_id)
  597. raise
  598. except Exception as exc:
  599. state.last_processed_at = None
  600. # Account failures still apply if this print ended during the
  601. # request, but an old key or generation must not block new work.
  602. if (
  603. isinstance(exc, _ApiError)
  604. and exc.error_code in ACCOUNT_ERROR_CODES
  605. and generation == self._generation
  606. and (self._configuration is None or settings["api_key"] == self._configuration[0])
  607. ):
  608. self._block_account(settings["api_key"], exc.error_code)
  609. logger.debug(
  610. "OctoEverywhere inspection failed for printer %s (error_code=%s); inspections stopped",
  611. printer_id,
  612. exc.error_code,
  613. )
  614. return
  615. if not self._is_current(printer_id, state, status, generation):
  616. state.next_check_at = max(
  617. state.next_check_at,
  618. time.monotonic()
  619. + _process_interval(state.interval, settings["poll_interval"], state.recommended_interval),
  620. )
  621. logger.debug(
  622. "OctoEverywhere inspection discarded for printer %s: print status or settings changed", printer_id
  623. )
  624. return
  625. if processing and state.context:
  626. # Keep the fallback for the rest of this context. Retrying on
  627. # the next scheduled poll also respects the API's rate limit.
  628. if isinstance(exc, _ApiError) and exc.context_missing:
  629. state.context = None # Recreate after backoff; preserve actions already performed.
  630. state.recommended_interval = None
  631. elif isinstance(exc, httpx.RequestError) or (
  632. isinstance(exc, _ApiError)
  633. and exc.status_code is not None
  634. and exc.status_code >= 500
  635. and exc.error_code in (None, "OE_INTERNAL_ERROR", "OE_BACKEND_THROTTLED")
  636. ):
  637. state.context.process_url = state.context.fallback_url
  638. state.failures += 1
  639. retry_after = exc.retry_after if isinstance(exc, _ApiError) else 0
  640. delay = max(
  641. _process_interval(state.interval, settings["poll_interval"], state.recommended_interval),
  642. ERROR_RETRY_INTERVAL * 2 ** min(state.failures - 1, 4),
  643. retry_after,
  644. )
  645. state.next_check_at = time.monotonic() + delay
  646. if isinstance(exc, _ApiError):
  647. reason = str(exc)
  648. elif isinstance(exc, httpx.TimeoutException):
  649. reason = "OctoEverywhere request timed out. Detection will retry automatically."
  650. elif isinstance(exc, httpx.RequestError):
  651. reason = "Could not reach OctoEverywhere. Check the internet connection."
  652. else:
  653. reason = "OctoEverywhere detection failed. Detection will retry automatically."
  654. self._no_verdict(printer_id, state, reason, exc.error_code if isinstance(exc, _ApiError) else None)
  655. logger.debug(
  656. "OctoEverywhere inspection failed for printer %s (error_code=%s, retry_in=%.1fs): %s",
  657. printer_id,
  658. state.error_code,
  659. delay,
  660. reason,
  661. )
  662. return
  663. if not self._is_current(printer_id, state, status, generation):
  664. logger.debug(
  665. "OctoEverywhere inspection discarded for printer %s: print status or settings changed", printer_id
  666. )
  667. return
  668. state.recommended_interval = recommended_interval
  669. state.last_processed_at = time.monotonic()
  670. state.next_check_at = state.last_processed_at + _process_interval(
  671. state.interval, settings["poll_interval"], state.recommended_interval
  672. )
  673. state.frame_count += 1
  674. state.print_quality = print_quality
  675. state.verdict = "failure" if pause else "warning" if warning else "safe"
  676. state.error = None
  677. state.error_code = None
  678. state.failures = 0
  679. self._refresh_last_error()
  680. logger.debug(
  681. "OctoEverywhere inspection result for printer %s: frame=%s, verdict=%s, print_quality=%s, "
  682. "warning_suggested=%s, pause_suggested=%s, faster_inspection_suggested=%s, "
  683. "minimum_interval=%ss, next_check_in=%ss",
  684. printer_id,
  685. state.frame_count,
  686. state.verdict,
  687. print_quality,
  688. warning,
  689. pause,
  690. faster_inspection_suggested,
  691. state.interval,
  692. _process_interval(state.interval, settings["poll_interval"], state.recommended_interval),
  693. )
  694. task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
  695. if state.verdict != "safe":
  696. self._history.appendleft(
  697. {
  698. "printer_id": printer_id,
  699. "task_name": task_name,
  700. "timestamp": datetime.now(timezone.utc).isoformat(),
  701. "class": state.verdict,
  702. "print_quality": print_quality,
  703. }
  704. )
  705. notification_due = not state.warning_fired or (
  706. state.last_notification_at is not None
  707. and time.monotonic() - state.last_notification_at >= NOTIFICATION_REPEAT_INTERVAL
  708. )
  709. if pause and not state.action_fired:
  710. action = settings["action"]
  711. # A pause or power cut always notifies, even after a warning: the
  712. # user needs to know the printer stopped. Notify-only follows the
  713. # same reminder cadence as warnings.
  714. if action == "notify" and not notification_due:
  715. state.action_fired = True
  716. return
  717. elif (warning or pause) and notification_due:
  718. # Only a fresh positive assessment can trigger a reminder. Never
  719. # repeat hardware control, even if Gadget still suggests pausing.
  720. action = "notify"
  721. else:
  722. return
  723. # Preparation is still cancellable: a print/settings change before any
  724. # side effect must prevent stale hardware control.
  725. printer_name = await self._prepare_action(printer_id)
  726. if not self._is_current(printer_id, state, status, generation):
  727. return
  728. # Once committed, a pause/disconnect or autosave must neither interrupt
  729. # the remaining plug-off/notification nor make this print act twice.
  730. state.action_fired = state.action_fired or pause
  731. state.warning_fired = True
  732. # Reserve the reminder deadline before yielding, including when a
  733. # settings save cancels this check while the notification is in flight.
  734. state.last_notification_at = time.monotonic()
  735. action_task = asyncio.create_task(
  736. self._dispatch_action(printer_id, action, task_name, print_quality, frame, printer_name=printer_name)
  737. )
  738. self._actions.add(action_task)
  739. action_task.add_done_callback(self._action_done)
  740. await asyncio.shield(action_task)
  741. async def _prepare_action(self, printer_id: int) -> str:
  742. from backend.app.services.obico_actions import _get_printer_name
  743. return await _get_printer_name(printer_id)
  744. def _action_done(self, task: asyncio.Task):
  745. self._actions.discard(task)
  746. if not task.cancelled():
  747. error = task.exception()
  748. if error is not None:
  749. logger.error("OctoEverywhere action task failed: %s", type(error).__name__)
  750. async def _dispatch_action(
  751. self,
  752. printer_id: int,
  753. action: str,
  754. task_name: str,
  755. print_quality: int,
  756. frame: bytes | None = None,
  757. *,
  758. printer_name: str | None = None,
  759. ):
  760. from backend.app.services.octoeverywhere_actions import execute_action
  761. logger.warning(
  762. "OctoEverywhere: print issue on printer %s (quality=%s) — action=%s", printer_id, print_quality, action
  763. )
  764. try:
  765. await execute_action(printer_id, action, task_name, print_quality, frame, printer_name=printer_name)
  766. except Exception as exc:
  767. self._set_last_error("OctoEverywhere could not execute the configured failure action.")
  768. logger.error("OctoEverywhere action dispatch failed: %s", type(exc).__name__)
  769. # ---- queries ----
  770. def get_per_printer(self) -> dict:
  771. return {
  772. printer_id: {
  773. "class": "error" if self._blocked_error_code or state.error else state.verdict,
  774. "print_quality": state.print_quality,
  775. "frame_count": state.frame_count,
  776. "error": API_ERRORS[self._blocked_error_code] if self._blocked_error_code else state.error,
  777. "error_code": self._blocked_error_code or state.error_code,
  778. }
  779. for printer_id, state in self._states.items()
  780. }
  781. def get_status(self) -> dict:
  782. return {
  783. "is_running": self._task is not None and not self._task.done(),
  784. "last_error": self._last_error,
  785. "last_error_code": self._last_error_code,
  786. "per_printer": self.get_per_printer(),
  787. "history": list(self._history),
  788. }
  789. async def test_connection(self, api_key: str, confidence: str = "medium") -> dict:
  790. """Validate a key with a context request; never upload a camera frame."""
  791. generation = self._generation
  792. api_key = api_key.strip()
  793. if not api_key:
  794. return {
  795. "ok": False,
  796. "status_code": None,
  797. "error": "An OctoEverywhere Gadget API key is required.",
  798. "error_code": None,
  799. }
  800. try:
  801. async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT, follow_redirects=False) as client:
  802. _, status_code = await self._create_context(client, api_key, confidence)
  803. except _ApiError as exc:
  804. if (
  805. generation == self._generation
  806. and self._configuration
  807. and api_key == self._configuration[0]
  808. and exc.error_code in ACCOUNT_ERROR_CODES
  809. ):
  810. self._block_account(api_key, exc.error_code)
  811. return {"ok": False, "status_code": exc.status_code, "error": str(exc), "error_code": exc.error_code}
  812. except httpx.TimeoutException:
  813. return {"ok": False, "status_code": None, "error": "OctoEverywhere request timed out.", "error_code": None}
  814. except Exception:
  815. return {
  816. "ok": False,
  817. "status_code": None,
  818. "error": "Could not connect to OctoEverywhere.",
  819. "error_code": None,
  820. }
  821. if generation == self._generation and self._configuration and api_key == self._configuration[0]:
  822. self._clear_account_error()
  823. return {"ok": True, "status_code": status_code, "error": None, "error_code": None}
  824. octoeverywhere_detection_service = OctoEverywhereDetectionService()